LaunchKit
← All posts
· 16 min read · by The LaunchKit team · 0 views

render json, and the bytes it actually sends

A Rails action that answers JSON is one line, and the line hides four decisions. render json: @post reaches a renderer, then to_json, then as_json, then an encoder that may or may not rewrite your < characters depending on a config flag whose default changed in Rails 8.1. Every failure in that chain is silent: wrong bytes, a filter that stopped filtering, a 200 where you wanted a 204.

Everything below was run in a scratch Rails app on rails 8.1.3.1, Ruby 4.0.5, puma 8.0.2 and PostgreSQL 17.7, with config.load_defaults 8.1. Outputs are pasted from curl, bin/rails runner and the development log. The 18 examples that hold the claims up are in one integration test, printed at the end.

The five lines that turn an object into a body

render json: is not a special case in the controller. It is an entry in a renderer registry, and the whole of it is here, at actionpack-8.1.3.1/lib/action_controller/metal/renderers.rb:169:

add :json do |json, options|
  json_options = options.except(:callback, :content_type, :status)
  json_options[:escape] ||= false if !self.class.escape_json_responses? && options[:callback].blank?
  json = json.to_json(json_options) unless json.kind_of?(String)

  if options[:callback].present?
    if media_type.nil? || media_type == Mime[:json]
      self.content_type = :js
    end

    "/**/#{options[:callback]}(#{json})"
  else
    self.content_type = :json if media_type.nil?
    json
  end
end

Three things in there decide most of what follows. options.except(:callback, :content_type, :status) means every other key you passed to render is forwarded to to_json verbatim. unless json.kind_of?(String) means a String argument is written to the socket untouched. And self.content_type = :json if media_type.nil? means the JSON content type is a default, not a rule: anything that set the media type first wins.

To see exactly what arrives at the model, I put a logging to_json and as_json on a subclass and hit two actions:

SPY to_json args=[{escape: false}]
SPY as_json options=nil
SPY to_json args=[{only: [:id], escape: false}]
SPY as_json options={only: [:id]}

So render json: record becomes record.to_json(escape: false), and the :escape key is consumed by the encoder rather than passed down: as_json receives nil in the first case and the options hash you actually wrote in the second.

Options ride through render and die at a String

only:, except:, methods: and include: are not options render understands. They are serializable_hash options, read in activerecord-8.1.3.1/lib/active_record/serialization.rb:13, and they reach it only because of the options.except(...) line above. That distinction is invisible until the argument is a String, at which point they are dropped with no warning at all.

Four actions, one record, one dev server:

def slim
  render json: Post.find(params[:id]), only: [:id, :title], methods: [:price]
end

def prebuilt_with_only
  render json: Post.find(params[:id]).to_json, only: [:id]
end

def nested
  render json: { post: Post.find(params[:id]).to_json }
end
$ curl -s http://127.0.0.1:3098/articles/1/slim
{"id":1,"title":"Post 0","price":19.99}

$ curl -s http://127.0.0.1:3098/articles/1/prebuilt_with_only
{"id":1,"title":"Post 0","body":"body 0","price_cents":1999,"amount":"19.99","published_at":"2026-09-26T10:00:00.000Z","author_id":1,"created_at":"2026-09-27T08:27:00.528Z","updated_at":"2026-09-27T08:27:00.528Z"}

$ curl -s http://127.0.0.1:3098/articles/1/nested
{"post":"{\"id\":1,\"title\":\"Post 0\",\"body\":\"body 0\",\"price_cents\":1999,\"amount\":\"19.99\",\"published_at\":\"2026-09-26T10:00:00.000Z\",\"author_id\":1,\"created_at\":\"2026-09-27T08:27:00.528Z\",\"updated_at\":\"2026-09-27T08:27:00.528Z\"}"}

The second response is a 200 carrying every column of the row including whatever you were trying to hide with only:. That is the shape of a leak: somebody adds a to_json while debugging, the test asserts response.parsed_body["id"], and the filter is gone.

The third is the double encode. { post: something.to_json } puts a JSON string inside a JSON object, so the client gets a value it has to JSON.parse a second time. The rule that avoids both: never call to_json yourself in a controller. Hand render json: the object.

Rails 8.1 changed the bytes twice, and neither change is in your controller

Two defaults flipped in load_defaults 8.1, in railties-8.1.3.1/lib/rails/application/configuration.rb at lines 352 and 361:

if respond_to?(:action_controller)
  action_controller.escape_json_responses = false
  action_controller.action_on_path_relative_redirect = :raise
end
...
if respond_to?(:active_support)
  active_support.escape_js_separators_in_json = false
end

escape_json_responses is the one you will notice. ActiveSupport::JSON::Encoding.escape_html_entities_in_json is still true, so to_json still rewrites <, > and & as \u003c, \u003e and \u0026. But the renderer now passes escape: false, which turns the whole escaping pass off for the response body only. Two actions returning the same Author row, one handing over the object and one calling to_json first:

def object
  render json: Author.find(params[:id])
end

def prebuilt
  render json: Author.find(params[:id]).to_json
end
$ curl -s http://127.0.0.1:3098/articles/1/object
{"id":1,"name":"Ada <b>Lovelace</b>","email":"ada@example.com","created_at":"2026-09-27T08:27:00.520Z","updated_at":"2026-09-27T08:27:00.520Z"}

$ curl -s http://127.0.0.1:3098/articles/1/prebuilt
{"id":1,"name":"Ada \u003cb\u003eLovelace\u003c/b\u003e","email":"ada@example.com","created_at":"2026-09-27T08:27:00.520Z","updated_at":"2026-09-27T08:27:00.520Z"}

143 bytes against 163, same record, same controller, same Rails. Both parse to the same object, so a test that goes through response.parsed_body cannot tell them apart. A test that asserts on response.body, a recorded HTTP fixture, or a downstream signature over the raw bytes all can, and this is the upgrade that breaks them.

Keeping the old behaviour is possible and is already on the clock:

$ bin/rails runner 'ActionController.deprecator.behavior = :stderr; ActionController::Base.escape_json_responses = true'
DEPRECATION WARNING: Setting action_controller.escape_json_responses = true is deprecated and will have no effect in Rails 8.2. Set it to `false`, or remove the config. (called from <main> at bin/rails:4)

The second change is not in Action Pack at all and I only found it because a test failed. Rails 8.1's schema dumper sorts columns:

$ for v in 7.2.0 8.0.4 8.1.0; do ... done
7.2.0: 190:          columns.each do |column|
8.0.4: 195:          columns.each do |column|
8.1.0: 195:          columns.sort_by(&:name).each do |column|

render json: record emits keys in column_names order, and column_names is whatever order the database has. A database built by db:migrate has declaration order; one built by db:test:prepare from schema.rb has alphabetical order. Same app, same commit, two answers:

$ bin/rails runner 'puts Post.column_names.inspect'
["id", "title", "body", "price_cents", "amount", "published_at", "author_id", "created_at", "updated_at"]

$ RAILS_ENV=test bin/rails runner 'puts Post.column_names.inspect'
["id", "amount", "author_id", "body", "created_at", "price_cents", "published_at", "title", "updated_at"]

Nothing in JSON says key order is meaningful and no sane client depends on it. Snapshot tests and byte-level response assertions do depend on it, and on Rails 8.1 they will pass locally against your migrated development database and fail in CI against a schema-loaded one. The fix is to stop asserting on the body string, not to pin the column order.

What a column turns into

Two conversions surprise people every time, and both are in as_json rather than the encoder.

A decimal column comes out as a JSON string, because BigDecimal#as_json is to_s. A datetime comes out with exactly three decimal places and a Z, because ActiveSupport::JSON::Encoding.time_precision is 3:

$ curl -s http://127.0.0.1:3098/articles/1/show
{"id":1,"title":"Post 0","body":"body 0","price_cents":1999,"amount":"19.99","published_at":"2026-09-26T10:00:00.000Z","author_id":1,"created_at":"2026-09-27T08:27:00.528Z","updated_at":"2026-09-27T08:27:00.528Z"}

"amount":"19.99" is a string and "price_cents":1999 is a number. That is correct rather than annoying: a float would lose the value, and a JavaScript client doing JSON.parse on a decimal number is exactly how 19.90 becomes 19.9. Store money in an integer column of cents and the question does not arise, which is the argument in money and decimals.

ActiveSupport::JSON.encode and JSON.generate are not the same function, and they differ twice in one line:

$ bin/rails runner 'h = { at: Time.utc(2026,9,26,10,0,0), amount: BigDecimal("19.99"), s: "a<b" }; puts ActiveSupport::JSON.encode(h); puts JSON.generate(h)'
{"at":"2026-09-26T10:00:00.000Z","amount":"19.99","s":"a\u003cb"}
{"at":"2026-09-26 10:00:00 UTC","amount":"19.99","s":"a<b"}

The timestamp is the as_json difference: JSON.generate calls to_s on the Time and produces something no client can parse as a timestamp. The a<b is the encoder difference, and it is the same escape_html_entities_in_json from two sections up, which is worth holding next to the render json: output above. ActiveSupport::JSON.encode escapes the <. The renderer, on load_defaults 8.1, does not. So the helper and the response body disagree about the bytes, and only the response body changed in 8.1. Reach for render json: in a controller and ActiveSupport::JSON.encode outside one, never the bare JSON module.

Status, content type, and the body that is not empty

status: and content_type: are stripped from the options before to_json sees them, so they are the two keys you can pass freely without changing the payload:

def created
  render json: { id: 1 }, status: :created, content_type: "application/vnd.api+json"
end
$ curl -s -o /tmp/b.txt -w "status=%{http_code} ctype=%{content_type}\n" http://127.0.0.1:3098/articles/created
status=201 ctype=application/vnd.api+json; charset=utf-8
$ cat /tmp/b.txt
{"id":1}

render json: nil is the one that catches people. It is not the same as head :no_content:

$ curl -s -o /tmp/b.txt -w "status=%{http_code} ctype=%{content_type}\n" http://127.0.0.1:3098/articles/nothing
status=200 ctype=application/json; charset=utf-8
$ cat /tmp/b.txt
null

200, application/json, four bytes. A client checking if (response.data) sees a falsy value and a client checking the status sees success. If you mean empty, head :no_content gives a real 204 with no content type at all, and the related trap of an action that renders nothing by accident is in API-only mode.

Validation errors have two shapes and you want the second one

render json: post.errors is the reflex, and it serialises to field names mapped to translated sentences. post.errors.details serialises to symbol codes a client can branch on:

$ curl -s http://127.0.0.1:3098/articles/invalid
{"errors":{"author":["must exist"],"title":["can't be blank"]},"details":{"author":[{"error":"blank"}],"title":[{"error":"blank"}]}}

Ship both. The first is what a form renders under the input, the second is what survives a locale change, and the details codes carry their interpolation arguments too: a length validation shows up as {"error":"too_short","count":5}.

include: is one query per record

include: reads an association per record, in as_json, after the controller has already decided it is done querying. Nothing in the controller looks like an N+1 and it is one:

def with_comments
  render json: Post.limit(3), include: :comments
end

def with_comments_preloaded
  render json: Post.includes(:comments).limit(3), include: :comments
end

Three posts, four queries. The development log, with config.colorize_logging = false and the ↳ caller lines dropped:

Processing by ArticlesController#with_comments as */*
  Post Load (9.2ms)  SELECT "posts".* FROM "posts" LIMIT 3 /*action='with_comments',application='RailsRenderJson',controller='articles'*/
  Comment Load (0.5ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1 /*action='with_comments',application='RailsRenderJson',controller='articles'*/
  Comment Load (0.2ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 2 /*action='with_comments',application='RailsRenderJson',controller='articles'*/
  Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 3 /*action='with_comments',application='RailsRenderJson',controller='articles'*/
Completed 200 OK in 40ms (Views: 8.6ms | ActiveRecord: 18.9ms (4 queries, 0 cached) | GC: 0.0ms)

Processing by ArticlesController#with_comments_preloaded as */*
  Post Load (0.3ms)  SELECT "posts".* FROM "posts" LIMIT 3 /*action='with_comments_preloaded',application='RailsRenderJson',controller='articles'*/
  Comment Load (0.3ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3) /*action='with_comments_preloaded',application='RailsRenderJson',controller='articles'*/
Completed 200 OK in 5ms (Views: 4.0ms | ActiveRecord: 0.6ms (2 queries, 0 cached) | GC: 0.0ms)

Ignore the milliseconds, which were taken on a laptop under a load average of 13 and mean nothing. The query counts are the claim, and the integration test asserts them rather than eyeballing the log.

The includes and the include: are two different words doing two different jobs in the same line, which is a naming accident nobody can fix now. The broader version of this problem, and the tooling that catches it before a user does, is in N+1 queries.

The dead end: the escape option does not break a zero-arity as_json

The escape: false option the renderer appends looked like an upgrade hazard, and it is not. Having read that line in the renderer, I expected every model in the wild carrying a zero-argument as_json to start raising on a Rails 8.1 upgrade:

class BadPost < ApplicationRecord
  self.table_name = "posts"

  def as_json
    { id: id, title: title }
  end
end

An as_json that takes no arguments is a common enough shortcut, and if the renderer started passing a non-empty options hash where it used to pass none, that is an ArgumentError on every request. It is not. The encoder strips :escape before it calls as_json, so the model still receives nil:

$ curl -s http://127.0.0.1:3098/articles/1/zero_arity
{"id":1,"title":"Post 0"}

What does break it is an option you wrote yourself, which has nothing to do with the version:

$ curl -s -o /dev/null -w "status=%{http_code}\n" http://127.0.0.1:3098/articles/1/zero_arity_with_only
status=500
ArgumentError (wrong number of arguments (given 1, expected 0)):

So the zero-arity override is a bomb with a delay fuse rather than an upgrade hazard. It works through render json: record, through to_json with no arguments, inside an array and nested in a hash, and it dies the day somebody adds only: four months later. Write def as_json(options = nil) and pass the options to super, or accept that only: and except: will never work on that model again.

That last clause is worth its own line, because it is the quieter half. An override that does super().merge("extra" => x) with empty parens throws the caller's options away. Leaky.first.as_json(only: [:id]) returned all nine columns plus the extra key, no error, in a model I wrote to check exactly that.

callback: is still in the renderer and still answers 422

The JSONP branch of the renderer has not been removed. It is also unreachable over a plain GET, because append_after_action :verify_same_origin_request from ActionController::RequestForgeryProtection fires on any non-XHR response whose media type matches %r(\A(?:text|application)/javascript), and the callback: branch sets exactly that content type:

$ curl -s -o /dev/null -w "status=%{http_code} ctype=%{content_type}\n" http://127.0.0.1:3098/articles/jsonp
status=422 ctype=text/html; charset=UTF-8

The development log for that request, with the backtrace after the first frame cut:

Processing by ArticlesController#jsonp as */*
Security warning: an embedded <script> tag on another site requested protected JavaScript. If you know what you're doing, go ahead and disable forgery protection on this action to permit cross-origin JavaScript embedding.
Completed 422 Unprocessable Content in 1ms (Views: 0.1ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)

ActionController::InvalidCrossOriginRequest (Security warning: an embedded <script> tag on another site requested protected JavaScript. If you know what you're doing, go ahead and disable forgery protection on this action to permit cross-origin JavaScript embedding.):

actionpack (8.1.3.1) lib/action_controller/metal/request_forgery_protection.rb:441:in 'ActionController::RequestForgeryProtection#verify_same_origin_request'

The body is built and then thrown away by an after_action. If you genuinely need JSONP in 2026 you have to turn forgery protection off on that action, which is the warning telling you to use CORS instead.

Where the time in a JSON response actually goes

The escape: false change was made for speed, so the fair question is how much. I measured the three slices of render json: Post.limit(20) separately, on a 4312-byte payload, with benchmark-ips:

$ bin/rails runner /tmp/bench_slices.rb
payload bytes=4312
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [arm64-darwin25]
Warming up --------------------------------------
 query + instantiate   338.000 i/100ms
 as_json (no encode)   632.000 i/100ms
      encode escaped     8.856k i/100ms
    encode unescaped    21.092k i/100ms
  to_json on records   621.000 i/100ms
Calculating -------------------------------------
 query + instantiate      3.490k (± 4.2%) i/s  (286.50 μs/i) -     17.576k in   5.035469s
 as_json (no encode)      6.649k (± 7.4%) i/s  (150.40 μs/i) -     33.496k in   5.037931s
      encode escaped     93.013k (± 3.0%) i/s   (10.75 μs/i) -    469.368k in   5.046243s
    encode unescaped    207.475k (± 4.4%) i/s    (4.82 μs/i) -      1.055M in   5.083021s
  to_json on records      6.174k (± 4.6%) i/s  (161.98 μs/i) -     31.050k in   5.029411s

Escaping is genuinely 2.2 times the cost of not escaping, and both numbers are irrelevant. The encoder is 10.75 μs of a 448 μs job. Turning escaping off saves 5.9 μs per response, about 1.3 percent, while as_json costs 150.40 μs and the query costs 286.50 μs. If a JSON endpoint here is slow, it is the query or the number of Active Record objects you instantiated, and no encoder swap touches either.

I also put three endpoints behind ApacheBench (/usr/sbin/ab -n 2000 -c 5) serving byte-identical 4312-byte payloads from render json: with escaping off, with escaping on, and from a jbuilder template, in RAILS_ENV=production on puma 8.0.2 in single mode with 5 threads. I am not reporting those numbers. This laptop was carrying a load average of 13.4 on 12 cores while I ran them and the same endpoint measured 624 requests per second and then 287 requests per second ten seconds apart. The in-process benchmark above survives that noise because benchmark-ips reports its own error margin; a throughput figure taken on a saturated machine does not, and publishing one would be inventing a fact.

The call, and what would change it

Hand render json: the object and let the renderer call to_json. Every to_json you write in a controller is a chance to lose your only: filter, double-encode a payload, or ship different bytes than the action next to it. When the response shape is more than only: and methods: can express, write a plain Ruby class with an as_json(options = nil) method and render that, rather than overriding as_json on the model where every other caller inherits your decision.

The cost of that position: render json: @post is the whole table, and it stays the whole table after the migration nobody told you about. The output above has nine keys because the posts table has nine columns, and it would have ten tomorrow with no code change and no failing test. only: [:id, :title] fixes that and is itself a list nothing validates: Post.first.as_json(only: [:id, :titel]) returned {"id" => 1} and raised nothing, so a rename drops a key the client was reading and the suite stays green. A serializer class is the thing that makes a response an interface rather than a projection of the table, and render json: never asks you to write one.

What would change it: a first-party serializer in Action Pack, or render json: refusing a bare ActiveRecord::Base argument the way render refuses an unknown template. Until one of those exists, the discipline is manual and the leak is one to_json away.

What this page does not cover

Alternative encoders. I did not install Oj, Oj.optimize_rails, alba, panko_serializer or active_model_serializers, so nothing above is a claim about them. The measurement that matters for that decision is in the benchmark: whatever an encoder does, it is competing for 10.75 μs of a 448 μs request, and a gem that also replaces as_json is competing for a different 150.40 μs.

Jbuilder beyond confirming that a hand-written template produced a byte-identical 4312-byte body for the same 20 rows. Streaming JSON, ActionController::Live and any response too large to build in memory. ActionController::API and the middleware it drops, which is API-only mode. Request-side JSON: parameter parsing, wrap_parameters and what params does with a JSON body are a separate chain with separate surprises. And JSON columns in PostgreSQL, which share a word with everything here and nothing else, covered in jsonb columns in Rails.

The integration test behind every claim above is 18 examples in one file:

$ bin/rails test test/integration/render_json_test.rb
Running 18 tests in a single process (parallelization threshold is 50)
Run options: --seed 10751

# Running:

..................

Finished in 0.261915s, 68.7246 runs/s, 156.5393 assertions/s.
18 runs, 41 assertions, 0 failures, 0 errors, 0 skips
#rails #api

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.