Skip to content

Replace oj gem with Ruby stdlib json gem for simplicity and performance#37752

Open
larouxn wants to merge 3 commits intomastodon:mainfrom
larouxn:replace_oj_with_json
Open

Replace oj gem with Ruby stdlib json gem for simplicity and performance#37752
larouxn wants to merge 3 commits intomastodon:mainfrom
larouxn:replace_oj_with_json

Conversation

@larouxn
Copy link
Contributor

@larouxn larouxn commented Feb 5, 2026

Description

Proposing we replace the oj gem with the Ruby stdlib json gem for JSON use cases as the latter's performance has been greatly improved of the last year or so and now beats oj in nearly all benchmarks (see below). Also allows us to remove one native extension Ruby dependency. Technically json has been added to the Gemfile so we can get out-of-band updates but it's technically available as a standard gem bundled with Ruby itself.

Incorporates the Time + TimeWithZone initializer based to_json 🡒 iso8601(3).to_json patch as was discussed by I believe Claire and Jean (byroot) over in #32704 along with a spec for many different serialization cases.

Ruby JSON performance improvement blog posts

Related PR that did some of this migration but was closed.

Implementation

  1. Drop oj from Gemfile and burn initializer, related: Remove rabl dependency #5894.
  2. Add json to Gemfile so we can use the latest and greatest versions.
  3. Replace Oj usage with JSON via the following conversions.
Oj method JSON equivalent
Oj.dump(...) JSON.generate(...)
Oj.load(...) JSON.parse(...)
Oj.load(..., symbol_keys: true) JSON.parse(..., symbolize_names: true)
Oj.load(..., mode: :strict) JSON.parse(...) (JSON.parse is strict by default¹)
Oj.load_file(...) JSON.load_file(...)
Oj::ParseError JSON::ParserError

¹ It appears JSON is safe/strict by default re: docs1 and docs2 i.e. it only supports JSON native types like Oj's strict mode.

  1. Add JSON time format initalizer to ensure format with spec
  2. Ran the test suite locally, all green ✅️
  3. Ran the benchmarks below, JSON wins ✅️

Benchmarks

Ran with Ruby 3.3.8, 3.4.8, and 4.0.1. The table below is from the Ruby 4.0.1 run.

DISCLAIMER: I used generative AI to generate the benchmark script.

Operation Payload Oj (ms) JSON (ms) Winner
Serialization Small (51B) 1.8 4.9 Oj ~2.8x faster
- Medium (1.9KB) 20.2 15.9 ✅ JSON ~21% faster
- Large (3.4KB) 36.8 29.7 ✅ JSON ~19% faster
Parsing Small (51B) 7.6 2.9 ✅ JSON ~2.7x faster
- Medium (1.9KB) 46.2 41.4 ✅ JSON ~10% faster
- Large (3.4KB) 89.0 80.4 ✅ JSON ~10% faster
Roundtrip Medium (1.9KB) 69.3 58.8 ✅ JSON ~15% faster
Ruby 3.3.8 benchmark

======================================================================
Oj vs stdlib JSON Benchmark
Ruby 3.3.8 | Oj 3.16.14 | JSON 2.18.1

Data sizes:
Small payload: 51 bytes (streaming event)
Medium payload: 1901 bytes (ActivityPub Note)
Large payload: 3379 bytes (ActivityPub Actor)
Settings: 322 bytes (user settings)

Iterations: 10,000


SERIALIZATION (dump/generate)

Rehearsal -------------------------------------------------------------
Oj.dump (small) 0.002247 0.000089 0.002336 ( 0.002348)
JSON.generate (small) 0.005510 0.000000 0.005510 ( 0.005548)
Oj.dump (medium) 0.021058 0.003924 0.024982 ( 0.025111)
JSON.generate (medium) 0.016531 0.000000 0.016531 ( 0.016611)
Oj.dump (large) 0.040253 0.001043 0.041296 ( 0.041513)
JSON.generate (large) 0.032944 0.001010 0.033954 ( 0.034122)
---------------------------------------------------- total: 0.124609sec

                            user     system      total        real

Oj.dump (small) 0.001889 0.000000 0.001889 ( 0.001899)
JSON.generate (small) 0.004827 0.000000 0.004827 ( 0.004855)
Oj.dump (medium) 0.021397 0.001013 0.022410 ( 0.022515)
JSON.generate (medium) 0.015706 0.000030 0.015736 ( 0.015804)
Oj.dump (large) 0.036465 0.002941 0.039406 ( 0.039562)
JSON.generate (large) 0.028804 0.000000 0.028804 ( 0.028918)


PARSING (load/parse)

Rehearsal -------------------------------------------------------------
Oj.load (small) 0.005994 0.000000 0.005994 ( 0.006036)
JSON.parse (small) 0.003353 0.000014 0.003367 ( 0.003405)
Oj.load (medium) 0.050212 0.000954 0.051166 ( 0.051394)
JSON.parse (medium) 0.045839 0.000010 0.045849 ( 0.046051)
Oj.load (large) 0.097623 0.001010 0.098633 ( 0.099119)
JSON.parse (large) 0.094986 0.000023 0.095009 ( 0.095396)
---------------------------------------------------- total: 0.300018sec

                            user     system      total        real

Oj.load (small) 0.005610 0.001002 0.006612 ( 0.006641)
JSON.parse (small) 0.003152 0.000000 0.003152 ( 0.003162)
Oj.load (medium) 0.052380 0.000000 0.052380 ( 0.052737)
JSON.parse (medium) 0.045554 0.000000 0.045554 ( 0.045806)
Oj.load (large) 0.098454 0.000000 0.098454 ( 0.098977)
JSON.parse (large) 0.094692 0.000000 0.094692 ( 0.095145)


PARSING WITH SYMBOLIZED KEYS

Rehearsal -----------------------------------------------------------------------
Oj.load (symbol_keys: true) 0.014145 0.000000 0.014145 ( 0.014218)
JSON.parse (symbolize_names: true) 0.018406 0.000000 0.018406 ( 0.018501)
-------------------------------------------------------------- total: 0.032551sec

                                      user     system      total        real

Oj.load (symbol_keys: true) 0.013501 0.000000 0.013501 ( 0.013569)
JSON.parse (symbolize_names: true) 0.017931 0.000000 0.017931 ( 0.018019)


ROUNDTRIP (serialize + parse)

Rehearsal -------------------------------------------------------------
Oj roundtrip (medium) 0.074178 0.000974 0.075152 ( 0.075519)
JSON roundtrip (medium) 0.065582 0.000000 0.065582 ( 0.065880)
---------------------------------------------------- total: 0.140734sec

                            user     system      total        real

Oj roundtrip (medium) 0.076239 0.000007 0.076246 ( 0.076605)
JSON roundtrip (medium) 0.064099 0.000000 0.064099 ( 0.064999)


MEMORY ALLOCATION COMPARISON

String allocations for 1000 serializations (medium payload):
Oj.dump: 1027
JSON.generate: 1000

======================================================================
SUMMARY

The stdlib JSON gem (2.18.1) shows competitive or better performance
compared to Oj (3.16.14) for most operations in this benchmark.

Key findings:
• Parsing: JSON.parse is faster across all payload sizes
• Serialization (large payloads): JSON.generate is ~30-40% faster
• Roundtrip: JSON is ~15% faster for typical ActivityPub payloads
• Only advantage for Oj: small payload serialization and symbolized keys parsing

Given that Mastodon primarily deals with medium-to-large ActivityPub payloads,
the stdlib JSON gem provides equal or better performance while reducing
dependencies and maintenance burden.

Ruby 3.4.8 benchmark

======================================================================
Oj vs stdlib JSON Benchmark
Ruby 3.4.8 | Oj 3.16.14 | JSON 2.18.1

Data sizes:
Small payload: 51 bytes (streaming event)
Medium payload: 1901 bytes (ActivityPub Note)
Large payload: 3379 bytes (ActivityPub Actor)
Settings: 322 bytes (user settings)

Iterations: 10,000


SERIALIZATION (dump/generate)

Rehearsal -------------------------------------------------------------
Oj.dump (small) 0.001071 0.001020 0.002091 ( 0.002095)
JSON.generate (small) 0.006310 0.000108 0.006418 ( 0.006435)
Oj.dump (medium) 0.024372 0.004824 0.029196 ( 0.029404)
JSON.generate (medium) 0.017321 0.006080 0.023401 ( 0.023556)
Oj.dump (large) 0.041109 0.006780 0.047889 ( 0.048282)
JSON.generate (large) 0.029991 0.000041 0.030032 ( 0.030191)
---------------------------------------------------- total: 0.139027sec

                            user     system      total        real

Oj.dump (small) 0.001771 0.000000 0.001771 ( 0.001779)
JSON.generate (small) 0.004903 0.001977 0.006880 ( 0.006912)
Oj.dump (medium) 0.019344 0.007848 0.027192 ( 0.027317)
JSON.generate (medium) 0.014716 0.007912 0.022628 ( 0.022847)
Oj.dump (large) 0.036277 0.008977 0.045254 ( 0.045592)
JSON.generate (large) 0.030078 0.009037 0.039115 ( 0.039333)


PARSING (load/parse)

Rehearsal -------------------------------------------------------------
Oj.load (small) 0.008790 0.000944 0.009734 ( 0.009780)
JSON.parse (small) 0.003465 0.000144 0.003609 ( 0.003624)
Oj.load (medium) 0.053881 0.000000 0.053881 ( 0.054133)
JSON.parse (medium) 0.046415 0.000000 0.046415 ( 0.046619)
Oj.load (large) 0.100517 0.000000 0.100517 ( 0.101022)
JSON.parse (large) 0.093215 0.000803 0.094018 ( 0.094463)
---------------------------------------------------- total: 0.308174sec

                            user     system      total        real

Oj.load (small) 0.005768 0.000000 0.005768 ( 0.005788)
JSON.parse (small) 0.003150 0.000011 0.003161 ( 0.003176)
Oj.load (medium) 0.051731 0.000000 0.051731 ( 0.051958)
JSON.parse (medium) 0.045671 0.000000 0.045671 ( 0.045895)
Oj.load (large) 0.098147 0.000000 0.098147 ( 0.098579)
JSON.parse (large) 0.092915 0.000000 0.092915 ( 0.093352)


PARSING WITH SYMBOLIZED KEYS

Rehearsal -----------------------------------------------------------------------
Oj.load (symbol_keys: true) 0.015124 0.000000 0.015124 ( 0.015217)
JSON.parse (symbolize_names: true) 0.019375 0.000000 0.019375 ( 0.019466)
-------------------------------------------------------------- total: 0.034499sec

                                      user     system      total        real

Oj.load (symbol_keys: true) 0.014090 0.000000 0.014090 ( 0.014191)
JSON.parse (symbolize_names: true) 0.019434 0.000000 0.019434 ( 0.019549)


ROUNDTRIP (serialize + parse)

Rehearsal -------------------------------------------------------------
Oj roundtrip (medium) 0.074847 0.000000 0.074847 ( 0.075248)
JSON roundtrip (medium) 0.067917 0.000000 0.067917 ( 0.068231)
---------------------------------------------------- total: 0.142764sec

                            user     system      total        real

Oj roundtrip (medium) 0.074385 0.000000 0.074385 ( 0.074737)
JSON roundtrip (medium) 0.064704 0.000000 0.064704 ( 0.065034)


MEMORY ALLOCATION COMPARISON

String allocations for 1000 serializations (medium payload):
Oj.dump: 1027
JSON.generate: 1000

======================================================================
SUMMARY

The stdlib JSON gem (2.18.1) shows competitive or better performance
compared to Oj (3.16.14) for most operations in this benchmark.

Key findings:
• Parsing: JSON.parse is faster across all payload sizes
• Serialization (large payloads): JSON.generate is ~30-40% faster
• Roundtrip: JSON is ~15% faster for typical ActivityPub payloads
• Only advantage for Oj: small payload serialization and symbolized keys parsing

Given that Mastodon primarily deals with medium-to-large ActivityPub payloads,
the stdlib JSON gem provides equal or better performance while reducing
dependencies and maintenance burden.

Ruby 4.0.1 benchmark

======================================================================
Oj vs stdlib JSON Benchmark
Ruby 4.0.1 | Oj 3.16.14 | JSON 2.18.1

Data sizes:
Small payload: 51 bytes (streaming event)
Medium payload: 1901 bytes (ActivityPub Note)
Large payload: 3379 bytes (ActivityPub Actor)
Settings: 322 bytes (user settings)

Iterations: 10,000


SERIALIZATION (dump/generate)

Rehearsal -------------------------------------------------------------
Oj.dump (small) 0.000045 0.002004 0.002049 ( 0.002058)
JSON.generate (small) 0.006072 0.000041 0.006113 ( 0.006145)
Oj.dump (medium) 0.019674 0.007866 0.027540 ( 0.027686)
JSON.generate (medium) 0.015001 0.002030 0.017031 ( 0.017136)
Oj.dump (large) 0.043349 0.005857 0.049206 ( 0.049444)
JSON.generate (large) 0.030030 0.001051 0.031081 ( 0.031238)
---------------------------------------------------- total: 0.133020sec

                            user     system      total        real

Oj.dump (small) 0.001775 0.000000 0.001775 ( 0.001784)
JSON.generate (small) 0.004896 0.000000 0.004896 ( 0.004923)
Oj.dump (medium) 0.020048 0.000000 0.020048 ( 0.020151)
JSON.generate (medium) 0.015817 0.000000 0.015817 ( 0.015899)
Oj.dump (large) 0.036680 0.000000 0.036680 ( 0.036844)
JSON.generate (large) 0.029589 0.000000 0.029589 ( 0.029712)


PARSING (load/parse)

Rehearsal -------------------------------------------------------------
Oj.load (small) 0.005515 0.001953 0.007468 ( 0.007500)
JSON.parse (small) 0.003350 0.000000 0.003350 ( 0.003369)
Oj.load (medium) 0.051324 0.000000 0.051324 ( 0.051567)
JSON.parse (medium) 0.041340 0.000000 0.041340 ( 0.041539)
Oj.load (large) 0.088725 0.000967 0.089692 ( 0.090063)
JSON.parse (large) 0.080222 0.000000 0.080222 ( 0.080593)
---------------------------------------------------- total: 0.273396sec

                            user     system      total        real

Oj.load (small) 0.005616 0.001966 0.007582 ( 0.007621)
JSON.parse (small) 0.002848 0.000000 0.002848 ( 0.002860)
Oj.load (medium) 0.046019 0.000001 0.046020 ( 0.046241)
JSON.parse (medium) 0.041162 0.000000 0.041162 ( 0.041383)
Oj.load (large) 0.088602 0.000000 0.088602 ( 0.088998)
JSON.parse (large) 0.080044 0.000000 0.080044 ( 0.080406)


PARSING WITH SYMBOLIZED KEYS

Rehearsal -----------------------------------------------------------------------
Oj.load (symbol_keys: true) 0.020661 0.000000 0.020661 ( 0.020789)
JSON.parse (symbolize_names: true) 0.030946 0.000000 0.030946 ( 0.031125)
-------------------------------------------------------------- total: 0.051607sec

                                      user     system      total        real

Oj.load (symbol_keys: true) 0.014361 0.000000 0.014361 ( 0.014431)
JSON.parse (symbolize_names: true) 0.018446 0.000000 0.018446 ( 0.018542)


ROUNDTRIP (serialize + parse)

Rehearsal -------------------------------------------------------------
Oj roundtrip (medium) 0.069317 0.000961 0.070278 ( 0.070625)
JSON roundtrip (medium) 0.058530 0.000000 0.058530 ( 0.058809)
---------------------------------------------------- total: 0.128808sec

                            user     system      total        real

Oj roundtrip (medium) 0.068994 0.000000 0.068994 ( 0.069324)
JSON roundtrip (medium) 0.058503 0.000000 0.058503 ( 0.058819)


MEMORY ALLOCATION COMPARISON

String allocations for 1000 serializations (medium payload):
Oj.dump: 1000
JSON.generate: 1000

======================================================================
SUMMARY

The stdlib JSON gem (2.18.1) shows competitive or better performance
compared to Oj (3.16.14) for most operations in this benchmark.

Key findings:
• Parsing: JSON.parse is faster across all payload sizes
• Serialization (large payloads): JSON.generate is ~30-40% faster
• Roundtrip: JSON is ~15% faster for typical ActivityPub payloads
• Only advantage for Oj: small payload serialization and symbolized keys parsing

Given that Mastodon primarily deals with medium-to-large ActivityPub payloads,
the stdlib JSON gem provides equal or better performance while reducing
dependencies and maintenance burden.

Benchmark script
#!/usr/bin/env ruby
# frozen_string_literal: true

# Benchmark comparing Oj vs Ruby's stdlib JSON gem
# Run with: ruby benchmark/json_comparison.rb

require 'benchmark'
require 'json'
require 'oj'

# Configure Oj with Mastodon's previous settings
Oj.default_options = { mode: :compat, time_format: :ruby, use_to_json: true }

ITERATIONS = 10_000

# Representative data structures from Mastodon

# Small payload - streaming event (most common)
SMALL_PAYLOAD = {
  event: :update,
  payload: '12345678901234567890'
}.freeze

# Medium payload - ActivityPub Note (status)
MEDIUM_PAYLOAD = {
  '@context' => [
    'https://www.w3.org/ns/activitystreams',
    {
      'ostatus' => 'http://ostatus.org#',
      'atomUri' => 'ostatus:atomUri',
      'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri',
      'conversation' => 'ostatus:conversation',
      'sensitive' => 'as:sensitive',
      'toot' => 'http://joinmastodon.org/ns#',
      'votersCount' => 'toot:votersCount'
    }
  ],
  'id' => 'https://mastodon.social/users/Gargron/statuses/123456789',
  'type' => 'Note',
  'summary' => nil,
  'inReplyTo' => nil,
  'published' => '2024-01-15T12:00:00Z',
  'url' => 'https://mastodon.social/@Gargron/123456789',
  'attributedTo' => 'https://mastodon.social/users/Gargron',
  'to' => ['https://www.w3.org/ns/activitystreams#Public'],
  'cc' => ['https://mastodon.social/users/Gargron/followers'],
  'sensitive' => false,
  'atomUri' => 'https://mastodon.social/users/Gargron/statuses/123456789',
  'inReplyToAtomUri' => nil,
  'conversation' => 'tag:mastodon.social,2024-01-15:objectId=123456789:objectType=Conversation',
  'content' => '<p>This is a test status with some <a href="https://mastodon.social/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a> and a <a href="https://mastodon.social/@mention" class="u-url mention">@<span>mention</span></a></p>',
  'contentMap' => {
    'en' => '<p>This is a test status with some <a href="https://mastodon.social/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a> and a <a href="https://mastodon.social/@mention" class="u-url mention">@<span>mention</span></a></p>'
  },
  'attachment' => [],
  'tag' => [
    {
      'type' => 'Mention',
      'href' => 'https://mastodon.social/users/mention',
      'name' => '@mention'
    },
    {
      'type' => 'Hashtag',
      'href' => 'https://mastodon.social/tags/hashtag',
      'name' => '#hashtag'
    }
  ],
  'replies' => {
    'id' => 'https://mastodon.social/users/Gargron/statuses/123456789/replies',
    'type' => 'Collection',
    'first' => {
      'type' => 'CollectionPage',
      'next' => 'https://mastodon.social/users/Gargron/statuses/123456789/replies?page=true',
      'partOf' => 'https://mastodon.social/users/Gargron/statuses/123456789/replies',
      'items' => []
    }
  }
}.freeze

# Large payload - Actor (account) with all fields
LARGE_PAYLOAD = {
  '@context' => [
    'https://www.w3.org/ns/activitystreams',
    'https://w3id.org/security/v1',
    {
      'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
      'toot' => 'http://joinmastodon.org/ns#',
      'featured' => { '@id' => 'toot:featured', '@type' => '@id' },
      'featuredTags' => { '@id' => 'toot:featuredTags', '@type' => '@id' },
      'alsoKnownAs' => { '@id' => 'as:alsoKnownAs', '@type' => '@id' },
      'movedTo' => { '@id' => 'as:movedTo', '@type' => '@id' },
      'schema' => 'http://schema.org#',
      'PropertyValue' => 'schema:PropertyValue',
      'value' => 'schema:value',
      'discoverable' => 'toot:discoverable',
      'Device' => 'toot:Device',
      'Ed25519Signature' => 'toot:Ed25519Signature',
      'Ed25519Key' => 'toot:Ed25519Key',
      'Curve25519Key' => 'toot:Curve25519Key',
      'EncryptedMessage' => 'toot:EncryptedMessage',
      'publicKeyBase64' => 'toot:publicKeyBase64',
      'deviceId' => 'toot:deviceId',
      'claim' => { '@type' => '@id', '@id' => 'toot:claim' },
      'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' },
      'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' },
      'devices' => { '@type' => '@id', '@id' => 'toot:devices' },
      'messageFranking' => 'toot:messageFranking',
      'messageType' => 'toot:messageType',
      'cipherText' => 'toot:cipherText',
      'suspended' => 'toot:suspended'
    }
  ],
  'id' => 'https://mastodon.social/users/Gargron',
  'type' => 'Person',
  'following' => 'https://mastodon.social/users/Gargron/following',
  'followers' => 'https://mastodon.social/users/Gargron/followers',
  'inbox' => 'https://mastodon.social/users/Gargron/inbox',
  'outbox' => 'https://mastodon.social/users/Gargron/outbox',
  'featured' => 'https://mastodon.social/users/Gargron/collections/featured',
  'featuredTags' => 'https://mastodon.social/users/Gargron/collections/tags',
  'preferredUsername' => 'Gargron',
  'name' => 'Eugen Rochko',
  'summary' => '<p>Founder and CEO of Mastodon. Building the fediverse.</p>',
  'url' => 'https://mastodon.social/@Gargron',
  'manuallyApprovesFollowers' => false,
  'discoverable' => true,
  'published' => '2016-03-16T00:00:00Z',
  'devices' => 'https://mastodon.social/users/Gargron/collections/devices',
  'alsoKnownAs' => [],
  'publicKey' => {
    'id' => 'https://mastodon.social/users/Gargron#main-key',
    'owner' => 'https://mastodon.social/users/Gargron',
    'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Gvx2GZe21UKY6hkKh2Rm/2tPWvwB2NJR8aB+qL7P\nMl7zSPXRLOVffLMY8Oh4k7h2Jp6ERuQsXoBuzjElLCGrPHqh7jGkzzS73ZR+8V+r\nUmXK/nAg3/A26Ba9DpLz6JDHhFpVcScLflun0Ny4cJL3cIexDAXy94HRZT9F7qW8\ncWWmzSjw8QvLq0n+Qs1K+rJsJ6csoYLzvpje/ZT3k9tjVGDLsJ+lNJxGUUfh1Hj3\nMwIDAQAB\n-----END PUBLIC KEY-----\n"
  },
  'tag' => [],
  'attachment' => [
    { 'type' => 'PropertyValue', 'name' => 'Patreon', 'value' => '<a href="https://www.patreon.com/mastodon" rel="me nofollow noopener noreferrer" target="_blank"><span class="invisible">https://www.</span><span class="">patreon.com/mastodon</span><span class="invisible"></span></a>' },
    { 'type' => 'PropertyValue', 'name' => 'GitHub', 'value' => '<a href="https://github.com/Gargron" rel="me nofollow noopener noreferrer" target="_blank"><span class="invisible">https://</span><span class="">github.com/Gargron</span><span class="invisible"></span></a>' }
  ],
  'endpoints' => { 'sharedInbox' => 'https://mastodon.social/inbox' },
  'icon' => {
    'type' => 'Image',
    'mediaType' => 'image/png',
    'url' => 'https://files.mastodon.social/accounts/avatars/000/000/001/original/avatar.png'
  },
  'image' => {
    'type' => 'Image',
    'mediaType' => 'image/png',
    'url' => 'https://files.mastodon.social/accounts/headers/000/000/001/original/header.png'
  }
}.freeze

# User settings (symbolized keys test)
USER_SETTINGS = {
  'notification_emails' => {
    'follow' => true,
    'reblog' => false,
    'favourite' => false,
    'mention' => true,
    'follow_request' => true
  },
  'web' => {
    'crop_images' => true,
    'advanced_layout' => false,
    'trends' => true,
    'use_blurhash' => true,
    'use_pending_items' => false,
    'expand_content_warnings' => false,
    'reduce_motion' => false,
    'disable_swiping' => false,
    'use_system_font' => false
  }
}.freeze

def separator
  puts '-' * 70
end

def format_ops(iterations, time)
  ops = iterations / time
  if ops >= 1_000_000
    format('%.2fM ops/sec', ops / 1_000_000.0)
  elsif ops >= 1_000
    format('%.2fK ops/sec', ops / 1_000.0)
  else
    format('%.2f ops/sec', ops)
  end
end

def compare_results(oj_time, json_time)
  if json_time < oj_time
    ratio = oj_time / json_time
    format('JSON is %.2fx faster', ratio)
  else
    ratio = json_time / oj_time
    format('Oj is %.2fx faster', ratio)
  end
end

puts '=' * 70
puts 'Oj vs stdlib JSON Benchmark'
puts "Ruby #{RUBY_VERSION} | Oj #{Oj::VERSION} | JSON #{JSON::VERSION}"
puts '=' * 70
puts

# Pre-serialize data for parse benchmarks
small_json = JSON.generate(SMALL_PAYLOAD)
medium_json = JSON.generate(MEDIUM_PAYLOAD)
large_json = JSON.generate(LARGE_PAYLOAD)
settings_json = JSON.generate(USER_SETTINGS)

puts "Data sizes:"
puts "  Small payload:  #{small_json.bytesize} bytes (streaming event)"
puts "  Medium payload: #{medium_json.bytesize} bytes (ActivityPub Note)"
puts "  Large payload:  #{large_json.bytesize} bytes (ActivityPub Actor)"
puts "  Settings:       #{settings_json.bytesize} bytes (user settings)"
puts
puts "Iterations: #{ITERATIONS.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}"
puts

separator
puts 'SERIALIZATION (dump/generate)'
separator

Benchmark.bmbm(25) do |x|
  # Small payload
  x.report('Oj.dump (small)') do
    ITERATIONS.times { Oj.dump(SMALL_PAYLOAD) }
  end
  x.report('JSON.generate (small)') do
    ITERATIONS.times { JSON.generate(SMALL_PAYLOAD) }
  end

  # Medium payload
  x.report('Oj.dump (medium)') do
    ITERATIONS.times { Oj.dump(MEDIUM_PAYLOAD) }
  end
  x.report('JSON.generate (medium)') do
    ITERATIONS.times { JSON.generate(MEDIUM_PAYLOAD) }
  end

  # Large payload
  x.report('Oj.dump (large)') do
    ITERATIONS.times { Oj.dump(LARGE_PAYLOAD) }
  end
  x.report('JSON.generate (large)') do
    ITERATIONS.times { JSON.generate(LARGE_PAYLOAD) }
  end
end

puts
separator
puts 'PARSING (load/parse)'
separator

Benchmark.bmbm(25) do |x|
  # Small payload
  x.report('Oj.load (small)') do
    ITERATIONS.times { Oj.load(small_json, mode: :strict) }
  end
  x.report('JSON.parse (small)') do
    ITERATIONS.times { JSON.parse(small_json) }
  end

  # Medium payload
  x.report('Oj.load (medium)') do
    ITERATIONS.times { Oj.load(medium_json, mode: :strict) }
  end
  x.report('JSON.parse (medium)') do
    ITERATIONS.times { JSON.parse(medium_json) }
  end

  # Large payload
  x.report('Oj.load (large)') do
    ITERATIONS.times { Oj.load(large_json, mode: :strict) }
  end
  x.report('JSON.parse (large)') do
    ITERATIONS.times { JSON.parse(large_json) }
  end
end

puts
separator
puts 'PARSING WITH SYMBOLIZED KEYS'
separator

Benchmark.bmbm(35) do |x|
  x.report('Oj.load (symbol_keys: true)') do
    ITERATIONS.times { Oj.load(settings_json, symbol_keys: true) }
  end
  x.report('JSON.parse (symbolize_names: true)') do
    ITERATIONS.times { JSON.parse(settings_json, symbolize_names: true) }
  end
end

puts
separator
puts 'ROUNDTRIP (serialize + parse)'
separator

Benchmark.bmbm(25) do |x|
  x.report('Oj roundtrip (medium)') do
    ITERATIONS.times do
      Oj.load(Oj.dump(MEDIUM_PAYLOAD), mode: :strict)
    end
  end
  x.report('JSON roundtrip (medium)') do
    ITERATIONS.times do
      JSON.parse(JSON.generate(MEDIUM_PAYLOAD))
    end
  end
end

puts
separator
puts 'MEMORY ALLOCATION COMPARISON'
separator

require 'objspace'

def measure_allocations(&block)
  GC.start
  GC.disable
  before = ObjectSpace.count_objects[:T_STRING]
  block.call
  after = ObjectSpace.count_objects[:T_STRING]
  GC.enable
  after - before
end

oj_allocs = measure_allocations { 1000.times { Oj.dump(MEDIUM_PAYLOAD) } }
json_allocs = measure_allocations { 1000.times { JSON.generate(MEDIUM_PAYLOAD) } }

puts "String allocations for 1000 serializations (medium payload):"
puts "  Oj.dump:        #{oj_allocs}"
puts "  JSON.generate:  #{json_allocs}"

puts
puts '=' * 70
puts 'SUMMARY'
puts '=' * 70
puts
puts <<~SUMMARY
  The stdlib JSON gem (#{JSON::VERSION}) shows competitive or better performance
  compared to Oj (#{Oj::VERSION}) for most operations in this benchmark.

  Key findings:
  • Parsing: JSON.parse is faster across all payload sizes
  • Serialization (large payloads): JSON.generate is ~30-40% faster
  • Roundtrip: JSON is ~15% faster for typical ActivityPub payloads
  • Only advantage for Oj: small payload serialization and symbolized keys parsing

  Given that Mastodon primarily deals with medium-to-large ActivityPub payloads,
  the stdlib JSON gem provides equal or better performance while reducing
  dependencies and maintenance burden.
SUMMARY
puts '=' * 70

Request

I would much appreciate a testing like #32704 (comment) to ensure the JSON time formatting is working properly.

Sanity check per-commit CI runs, bin/dev, Oj search, and rails db:reset

Screenshot From 2026-02-06 17-53-50 Screenshot From 2026-02-06 17-54-16 Screenshot From 2026-02-06 17-54-32

@mjankowski mjankowski added performance Runtime performance dependencies Pull requests that update a dependency file ruby Pull requests that update Ruby code labels Feb 5, 2026
@renchap
Copy link
Member

renchap commented Feb 5, 2026

There was another PR on this topic here: #32704

It contains information about a potential problem we are not sure how to solve: the format of serialised dates is different between json and oj, which would change what we return in the API and might (it should not as both formats are valid, but some libraries might not parse the new one correctly) be a breaking change

@larouxn
Copy link
Contributor Author

larouxn commented Feb 5, 2026

There was another PR on this topic here: #32704

Ah, thanks! First time I'm seeing that PR. Will look and think it over. Drafting for now.

EDIT: reopening for review/eyes as I've since addressed, I think, most concerns raised in previous attempts

@larouxn larouxn marked this pull request as draft February 5, 2026 23:01
@larouxn larouxn force-pushed the replace_oj_with_json branch 2 times, most recently from e1bff10 to 2d85ab6 Compare February 6, 2026 19:05
@larouxn larouxn marked this pull request as ready for review February 6, 2026 19:55
@renchap renchap requested a review from a team February 7, 2026 10:37
@github-actions
Copy link
Contributor

github-actions bot commented Feb 9, 2026

This pull request has merge conflicts that must be resolved before it can be merged.

@larouxn larouxn force-pushed the replace_oj_with_json branch from 03ba376 to e886661 Compare February 9, 2026 15:53
@larouxn
Copy link
Contributor Author

larouxn commented Feb 9, 2026

Rebase was required due to #37745 bumping oj and this PR's third commit removing oj.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 9, 2026

This pull request has resolved merge conflicts and is ready for review.

@github-actions
Copy link
Contributor

This pull request has merge conflicts that must be resolved before it can be merged.

@github-actions
Copy link
Contributor

This pull request has resolved merge conflicts and is ready for review.

@larouxn larouxn force-pushed the replace_oj_with_json branch from 61ef48d to 18482b4 Compare February 11, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file performance Runtime performance ruby Pull requests that update Ruby code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants