High-Performance JSON Serialization: Benchmarking Oj, Blueprinter, and Fast JSON API
The last place I look when an API endpoint is slow is the serializer, and that’s a mistake. On a typical list endpoint the serializer is where most of the time and almost all of the garbage collection happens — every rendered record goes through as_json, gets hash-allocated, then re-serialized, and the GC bill lands in the p99. The good news is that this is one of the few performance problems in Rails with a well-trodden fix path and measurable, boring wins. The 2026 landscape is different from the old “Oj or nothing” advice though: Rails 8.1 (September 2025) finally made Ruby’s native JSON.generate the default to_json encoder, which closed most of the encoder gap. The serializer layer is where the real work is now.
The 2026 landscape, sorted
- ActiveModel::Serializers is unmaintained and the slowest option by a wide margin. New code shouldn’t touch it; existing code should migrate off.
- Fast JSON API is dead — the gem was archived and unmaintained years ago. Its community fork lives on as jsonapi-serializer, which is what you actually want if you need JSON:API shape.
- Blueprinter is the fastest maintained view-oriented serializer and the one we standardized on: plain Ruby class per resource,
viewdefinitions, no reflection, no monkeypatching. - Rails 8.1’s native encoder removed the ActiveSupport JSON encoder overhead, so
render json: object.as_jsongot meaningfully faster on its own — butas_jsonstill builds the full hash tree, which is the expensive part.
The lesson: stop swapping encoders for wins and start fixing shape and allocation. Oj made sense when it was 4x faster than the ActiveSupport encoder; against Rails 8.1’s JSON.generate the margin is thinner, and Blueprinter’s real advantage is that it serializes from a defined view without building a full AR-object hash tree per record.
What we measure, and why it matters
On a production list endpoint (200 invoices with three associations each, ~35 KB of JSON), averaged over five runs on Rails 8.1 / Ruby 3.4:
- ActiveModel::Serializers + Oj: ~190 ms render, ~38 MB allocated
- jsonapi-serializer + Oj: ~110 ms render, ~21 MB allocated
- Blueprinter + Oj: ~65 ms render, ~9 MB allocated
- raw
as_json+JSON.generate: ~55 ms, ~7 MB allocated
Renderer plus encoder together. Blueprinter sits within 15% of hand-rolled as_json, and it buys you maintainability — associations, excludes, and view variants are declarative instead of built by hand. Allocation is the number to watch: a list endpoint that allocates 38 MB per request is allocating 38 MB per request even when the response is cached downstream, because the work happens before any cache check.
The setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# config/initializers/oj.rb
Oj.default_options = { mode: :rails, time_format: :xmlschema }
Oj.optimize_rails
# app/serializers/invoice_blueprint.rb
class InvoiceBlueprint < Blueprinter::Base
identifier :id
fields :number, :status, :total_cents, :due_on
association :customer, blueprint: CustomerBlueprint do |invoice, _|
invoice.customer # explicitly loaded, never lazily
end
view :export do
fields :currency, :memo, :created_at
end
end
# app/controllers/invoices_controller.rb
def index
invoices = Invoice
.includes(:customer, :line_items) # one query, three tables
.where(account: current_account)
.limit(200)
render json: InvoiceBlueprint.render(invoices, view: :index)
end
Three details decide whether this is fast or a regression. The includes must cover every association the blueprint touches, or each record pays an N+1 that dwarfs the serialization savings. The view argument means public endpoints and internal exports serialize differently from one class. And Oj in :rails mode keeps Rails’ Time and BigDecimal semantics — serializing total_cents as a raw integer is exactly what your payment-API consumers expect, and :xmlschema timestamps are what the frontend date parser was written for.
Production lessons
- Flat beats nested. A sideloaded, flat payload (records plus a
customersarray) renders faster and lets the client cache pieces. Recursive nested serialization inside a list is where the allocation graph explodes — profile it once and you’ll never write it again. - Don’t serialize through the encoder you don’t need. If you’re on Rails 8.1, measure before adding
Oj.optimize_rails; the native encoder may already be within noise for your shapes. We kept Oj for its parse speed on inbound webhooks and formode: :railsconsistency, not for the encoder margin. - Check the cache headers.
render json:+fresh_when(ETag) means unchanged collections skip rendering entirely. On our read-heavy endpoints, ETags cut responses that reach the serializer by ~70%, which is a bigger number than any serializer benchmark. - Escape sequences matter for API contracts. Oj’s default escapes differ subtly from
JSON.generatefor some non-ASCII and control characters. Lock your contract with a fixture test before flipping, or the frontend integration suite finds it for you. - Benchmark with real shapes. Microbenchmarks of
Oj.dump({a: 1})lie; a 200-record collection with associations is the shape that decides.
The compounding lesson: serialization performance is mostly not serialization — it’s eager loading, flat shapes, and not rendering what you don’t need. Pick Blueprinter (or jsonapi-serializer if you’re locked into JSON:API), back it with a native or Oj encoder, profile the allocation, and put an ETag in front. That combination took our worst list endpoint from ~190 ms to ~65 ms render time and cut process RSS by roughly a third, and it’s all maintainable, boring code.