Rails server side rendering against client side rendering, measured
A Rails application builds its HTML on the server, and the question under "ruby on rails server side rendering" is almost never whether that is true. It is whether it is expensive, and the answer people carry around comes from a JavaScript framework's marketing page rather than from a run. So this page renders the same 200 rows both ways in one application and measures both.
Everything below ran in a generated Rails 8.1.3.1 application on Ruby 4.0.5, puma 8.0.2,
turbo-rails 2.0.23, json 2.21.2 and PostgreSQL 17.7 on port 15432, on an Apple M2 Max with 12
cores. The server was booted in RAILS_ENV=production with WEB_CONCURRENCY=1 and
RAILS_MAX_THREADS=5, on 127.0.0.1, so every request in the ApacheBench numbers has zero network
latency and no TLS. That last condition matters more to the client-side side of the comparison than
to the server-side one, and the closing section says why.
The two pages, and what is in the first response
One controller serves both. The server-rendered action passes an Active Record relation to an ERB template; the client-rendered action renders an empty table and a module script that fetches the same rows as JSON.
class CatalogController < ApplicationController
def server
@products = Product.order(:id)
end
def client
end
def index
render json: Product.order(:id).select(:id, :name, :sku, :price_cents, :stock)
end
end
app/views/catalog/client.html.erb is the whole client-side implementation, inline so that nothing
about a bundler is in the measurement:
<h1>Catalog</h1>
<table id="catalog">
<thead><tr><th>Name</th><th>SKU</th><th>Price</th><th>Stock</th></tr></thead>
<tbody></tbody>
</table>
<script type="module">
const response = await fetch("/products.json")
const products = await response.json()
const body = document.querySelector("#catalog tbody")
for (const product of products) {
const row = document.createElement("tr")
for (const cell of [product.name, product.sku,
"$" + (product.price_cents / 100).toFixed(2), product.stock]) {
const td = document.createElement("td")
td.textContent = cell
row.appendChild(td)
}
body.appendChild(row)
}
</script>
The difference is visible with curl, which runs no JavaScript and is therefore the best available
stand-in for a crawler, a curl | grep in a support ticket, or a browser with a failed script
request:
$ curl -s http://127.0.0.1:3400/server | grep -c 'Widget 000'
1
$ curl -s http://127.0.0.1:3400/client | grep -c 'Widget 000'
0
$ curl -s http://127.0.0.1:3400/client | grep -A2 '<tbody>'
<tbody></tbody>
</table>
<script type="module">
That is the entire substantive difference between the two approaches, and everything else on this page is a cost attached to it.
Turbo Drive is not client side rendering
Turbo gets filed on the wrong side of this comparison constantly, including by people shipping it.
A Turbo Drive visit is an ordinary HTML GET. The default headers are one line in the turbo.js
that turbo-rails 2.0.23 ships:
Accept: "text/html, application/xhtml+xml"
The server renders the full page, Turbo replaces the <body> and merges the <head>, and no
template is evaluated in the browser. Turbo-Frame is the only request header Turbo adds on its
own, and only for frame and prefetch requests: in that same file it is set at line 3396 for a
prefetch and line 4781 for a frame navigation. A Rails application using Hotwire is a
server-rendered application that skips the reload, which is a different thing from a client-rendered
one and has a different cost profile.
What rendering 200 rows actually costs
The interesting number is not the page, it is the template. Rendering the ERB table 500 times in
process with ApplicationController.render, against encoding the same 200 records to JSON 500
times, with 100 warm-up iterations each:
def timed(n)
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
n.times { yield }
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) / n * 1000
end
products = Product.order(:id).to_a
fields = [:id, :name, :sku, :price_cents, :stock]
r = ->(tpl) { ApplicationController.render(template: tpl, assigns: { products: products }, layout: false) }
100.times { r.("catalog/server"); r.("catalog/plain"); products.as_json(only: fields).to_json }
printf("erb + number_to_currency %.3f ms\n", timed(500) { r.("catalog/server") })
printf("erb, plain interpolation %.3f ms\n", timed(500) { r.("catalog/plain") })
printf("as_json + to_json %.3f ms\n", timed(500) { products.as_json(only: fields).to_json })
printf("number_to_currency alone %.3f ms (200 calls)\n",
timed(500) { products.each { |p| number_to_currency(p.price_cents / 100.0) } })
bin/rails runner bench_all.rb in production mode:
ruby 4.0.5
rails 8.1.3.1
rows 200
erb + number_to_currency 7.781 ms
erb, plain interpolation 0.433 ms
as_json + to_json 1.103 ms
number_to_currency alone 7.325 ms (200 calls)
catalog/plain is the same table with $<%= product.price_cents / 100.0 %> in place of
<%= number_to_currency(product.price_cents / 100.0) %>. Nothing else differs. So 94 percent of the
cost of server-rendering this page was one helper, at roughly 37 microseconds per call, and the
remaining ERB work for 200 rows and 800 cells was 0.433 ms. Server-rendering the table was two and
a half times cheaper than serialising the same rows to JSON, before the client has parsed anything
or created a single DOM node.
This is the result that should change how the question is asked. "Is server side rendering slow in
Rails" is not answerable, because ERB evaluation is not where the time goes. What goes there is
whatever you called inside the loop: a currency formatter, an l() with a format, a link_to with
a URL helper, a cache block computing a digest, or an association that was not preloaded. Every
one of those is a per-row cost that a JSON endpoint quietly avoided by pushing the formatting onto
the client, and every one of them is fixable without changing where the HTML is built. Formatting
the price as "$%.2f" % (cents / 100.0) produces the same string for every row in this data set and
costs nothing measurable; number_to_currency earns its price when the currency, the delimiter and the
locale are not known at write time, which on a product catalogue they usually are. There is more on
what number_to_currency is doing in Rails money and decimals.
The throughput numbers, and what they are measuring
End to end, ab -n 2000 -c 10 against the booted production server, one Puma worker, five threads.
One run has the shape of the output:
=== ab -n 2000 -c 10 /server
Document Length: 26236 bytes
Failed requests: 0
Requests per second: 92.27 [#/sec] (mean)
50% 104
95% 134
=== ab -n 2000 -c 10 /client
Document Length: 2649 bytes
Failed requests: 0
Requests per second: 1048.46 [#/sec] (mean)
50% 8
95% 12
=== ab -n 2000 -c 10 /products.json
Document Length: 15454 bytes
Failed requests: 0
Requests per second: 430.90 [#/sec] (mean)
50% 22
95% 32
A single ab run on a laptop is not a figure, so here are five runs of each, sorted, requests per second:
/server : 90.42 93.44 95.56 96.35 96.86
/client : 901.29 1113.86 1166.61 1178.48 1187.79
/products.json : 388.52 400.91 403.37 414.88 416.90
Read against the benchmark above, the 95.56 median is not a verdict on server rendering. Take
number_to_currency out of the template and 7.3 of the 10.46 ms of worker capacity each request
consumes goes with it. The honest statement of these three lines is that this page, with this
helper called 200 times, cost 1/95.56 of a second of a single Puma worker, the JSON endpoint
serving the same rows cost 1/403.37, and the client-side page cost 1/1166.61 to serve and then
spent the JSON cost anyway, one round trip later.
A caveat on the 1166.61, since it is the number that looks best: it is throughput for a page that contains no data. A real single-page application's shell also carries a framework bundle, which this one does not, and the browser work of parsing that bundle and building 200 rows of DOM is absent from every number on this page. The methodology here, and what a Rails request costs when nothing unusual is in it, is in Ruby on Rails performance comparison.
Bytes on the wire, before and after gzip
The server-rendered page is 26236 bytes. The client-rendered path is 2649 for the shell plus 15454 for the JSON, 18103 in total, so HTML costs 8133 bytes more, about 45 percent. That is the number the argument is usually made with, and it does not survive compression:
$ for u in /server /client /products.json; do echo -n "$u gzip -9: "; curl -s http://127.0.0.1:3400$u | gzip -9 | wc -c; done
/server gzip -9: 3120
/client gzip -9: 979
/products.json gzip -9: 2586
3120 bytes for the whole server-rendered page, against 3565 for the shell and its JSON added
together. Once compressed the server-rendered page is the smaller of the two, and the 45 percent
penalty is gone in the other direction. Repeated markup is what a compressor is best at, and a table
of 200 rows is almost entirely repeated markup, while the JSON repeats its five key names 200 times
and has nothing else to give up. Only the JSON figure is stable: twelve runs of each gave 2586 bytes
every time for /products.json, against 3118 to 3126 for /server and 977 to 982 for /client,
because both HTML responses carry a randomly masked CSRF token in the head and the JSON does not.
That token is the subject of the next section.
One thing to check before quoting either figure about your own deployment: Rails does not compress
for you. bin/rails middleware in production on 8.1.3.1 prints Rack::Head,
Rack::ConditionalGet, Rack::ETag and Rack::TempfileReaper at the bottom of the stack and no
Rack::Deflater, and the responses above came back with no content-encoding header even when
asked with Accept-Encoding: gzip. Whatever sits in front of the application is doing that work, or
nobody is.
The dead end: the default ETag is worthless on any page with a CSRF token
Rack::ETag is in the stack, so a server-rendered page that has not changed ought to answer 304 and
send nothing. It does not, and working out why took longer than the rest of this page.
$ ET=$(curl -s -D - -o /dev/null http://127.0.0.1:3400/server | grep -i '^etag' | tr -d '\r' | awk '{print $2}')
$ echo $ET
W/"18a2864341fb6de5819c880f6a134229"
$ curl -s -o /dev/null -w "%{http_code}, %{size_download} bytes\n" -H "If-None-Match: $ET" http://127.0.0.1:3400/server
200, 26236 bytes
Two consecutive renders of a page whose data did not change, diffed:
$ curl -s http://127.0.0.1:3400/server > a.html; curl -s http://127.0.0.1:3400/server > b.html
$ diff a.html b.html
10c10
< <meta name="csrf-token" content="EC6jIR5Ea09P4MdjIB_h3ZPNyxfPl3KEqWBNKFFOgfm2FGC2_HeaFHSo_8XPOoIx5yC3i-uViwLZYfQjb3oJVA" />
---
> <meta name="csrf-token" content="TCd46VkoRdXQBscD-d5KAQ56RrPWtEIFegDwzoDH3sz4GDDXway3HAljWBETdko4EIujKcwm3d7LsZoCpwSYVg" />
One line, out of a 26236 byte response that is otherwise byte-identical. The token is masked with a
fresh pad per request, at actionpack-8.1.3.1/lib/action_controller/metal/request_forgery_protection.rb:546:
def mask_token(raw_token) # :doc:
one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
masked_token = one_time_pad + encrypted_csrf_token
encode_csrf_token(masked_token)
end
Rack::ETag digests the response body, so a random 88 characters in the <head> means the digest
is new every time. Every page in a default Rails application that uses csrf_meta_tags, which is
every page generated by rails new, has an ETag that can never match. It is not broken, and the
masking is there for a good reason, but the conditional GET it appears to offer is not real.
The fix is to stop letting the body decide, and set the validator from the data before the render happens:
def cached
@products = Product.order(:id)
return unless stale?(etag: Product.maximum(:updated_at), last_modified: Product.maximum(:updated_at))
render "server"
end
$ curl -s -D - -o /dev/null http://127.0.0.1:3400/cached | grep -i '^etag'
etag: W/"84e3cf237f0c9917fa73488b867f3654"
$ curl -s -D - -o /dev/null http://127.0.0.1:3400/cached | grep -i '^etag'
etag: W/"84e3cf237f0c9917fa73488b867f3654"
$ curl -s -o /dev/null -w "%{http_code}, %{size_download} bytes\n" -H "If-None-Match: W/\"84e3cf237f0c9917fa73488b867f3654\"" http://127.0.0.1:3400/cached
304, 0 bytes
Five ab runs of each, sorted, requests per second:
/cached 200 : 91.83 92.00 92.07 92.12 96.66
/cached 304 : 1159.42 1177.33 1225.30 1257.83 1301.48
1225.30 against the 1166.61 of the empty client-side shell. The server-rendered page that answers
304 is as cheap to serve as the page that contains nothing, and it arrives complete. The cost of
that line is real and is not hidden by it: Product.maximum(:updated_at) is a query on every
request, the validator is only as correct as the touching discipline on the records it reads, and a
page built from three models needs all three in the key or it will serve a stale 304 that no reload
clears. The mechanics of cache_key_with_version and what touch: true buys are in
Rails caching strategies.
The test that hides it
The two assertions about the CSRF token and the ETag pass in production mode and cannot fail in a
default test suite, because rails new writes this into config/environments/test.rb:29:
config.action_controller.allow_forgery_protection = false
With forgery protection off there is no token in the layout, so the two renders are byte-identical and the conditional GET works. Running the same eight-example file both ways:
$ DATABASE_PORT=15432 FORGERY=on bin/rails test test/integration/rendering_test.rb
........
Finished in 0.301738s, 26.5131 runs/s, 115.9947 assertions/s.
8 runs, 35 assertions, 0 failures, 0 errors, 0 skips
$ DATABASE_PORT=15432 bin/rails test test/integration/rendering_test.rb
.....F
Failure:
RenderingTest#test_the_default_etag_on_a_page_with_a_csrf_token_never_matches [test/integration/rendering_test.rb:67]:
Expected response to be a <2XX: success>, but was a <304: Not Modified>
A test asserting that HTTP caching works on a server-rendered page is green in the suite and wrong about production, in the default configuration, with nothing to indicate it.
The other dead end: a Turbo Frame request does not render less
Wrapping a section in turbo_frame_tag reads like it should let the server skip everything outside
the frame. It does not, and the gem says so in its own words. Turbo::Frames::FrameRequest in
turbo-rails 2.0.23 does exactly two things when it sees the Turbo-Frame header:
included do
layout -> { "turbo_rails/frame" if turbo_frame_request? }
etag { :frame if turbo_frame_request? }
The substituted layout, turbo-rails-2.0.23/app/views/layouts/turbo_rails/frame.html.erb, is an
<html> wrapper holding csrf_meta_tags, yield :head and yield, and nothing else. The comment
above that module calls the swap "merely a rendering optimization", adding that "Turbo
Frames knows how to fish out the relevant frame regardless". Measured on a page holding an <h1>
and a frame containing 200 rows:
GET /framed : 9444 bytes
GET /framed -H Turbo-Frame : 7764 bytes
GET /framed_scoped -H Turbo-Frame : 7500 bytes
$ curl -s -H 'Turbo-Frame: catalog' http://127.0.0.1:3400/framed | grep -c 'Widget '
200
$ curl -s -H 'Turbo-Frame: catalog' http://127.0.0.1:3400/framed | grep -c '<h1>Catalog</h1>'
1
1680 bytes of head removed, all 200 rows rendered, and the <h1> outside the frame rendered and
then discarded by the browser. Skipping the work needs your own branch, which is what
framed_scoped does:
def framed_scoped
@products = Product.order(:id)
render "framed_rows", layout: false if turbo_frame_request?
end
That got the response to 7500 bytes here, a saving of 264 bytes, because in this test page there is nothing outside the frame except a heading. The point is structural rather than about the 264: if the rest of your page is a sidebar with its own queries, a frame navigation runs all of it on every request unless the action says not to.
Where client rendering is the right answer
The position on this page: render on the server by default, and move a unit of the interface to the client when its state changes faster than a round trip and that state is local to the browser. A drag-to-reorder with live feedback, a canvas, a rich text editor, a filter over a list already in memory, anything that must keep working offline. Those are not "interactive pages", they are components whose state has no server in it, and Hotwire does not make them easier.
What would change the verdict on a given screen is not the framework and not these milliseconds. It is whether the thing the user is doing produces more than a few state changes per second. At that rate a server round trip is the wrong tool at any latency, and the fact that ERB is cheap is beside the point. Below it, the round trip is the cheaper engineering: one language, one place the data is formatted, one place a change ships, and a first response that contains the answer.
What this page does not cover
No browser was involved in any measurement here. There is no Largest Contentful Paint, no Time to Interactive, no hydration cost, and no number for how long a browser takes to build 200 rows of DOM from the JSON, because that needs a real browser and a real network and this page has neither. Any claim of that shape belongs to a measurement nobody on this page ran.
Also absent: React, Vue, Svelte, Inertia and every other library, none of which is installed here;
server-side rendering of a JavaScript framework from Ruby, which is a different subject with a
different cost; streaming and ActionView::StreamingTemplateRenderer, which changes when the first
byte leaves; and the effect of real network latency, which is the single condition that matters most
to the comparison and is exactly zero over 127.0.0.1. Two serial round trips across a 100 ms
connection cost 200 ms that one round trip does not, and that gap is larger than every difference
measured above put together.
Everything above ran in a generated Rails 8.1.3.1 application under /server, /client,
/cached, /framed and /products.json, with 200 Product rows in PostgreSQL 17.7 on port
15432, backed by an eight-example Minitest integration file that asserts the row counts in each
first response, the layout substitution on a frame request, the single-line CSRF diff, the ETag
mismatch and the 304.
Comments
No comments yet. Be the first.