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

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.

#rails #views #performance

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.