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

Ruby on Rails vs React

"Ruby on Rails vs React" is a comparison with a missing term, and the experiment below is what makes that concrete rather than pedantic. I built one filterable 600-row product table twice on one laptop: once as a Rails application rendering ERB through Hotwire, once as a React 19.3.0 application built by Vite. The React version needed a Rails controller to get its rows. Something has to own the schema, run the query and serialise the result, and React does none of those things, so the second application is not an alternative to the first. It is a second thing in front of it.

So the decision people are actually facing is narrower: does Rails render the HTML, or does Rails serve JSON to a client application that renders it. That question has measurable answers, and this page is those answers. Where a JavaScript runtime replaces Rails on the server rather than joining it, the comparison is Rails against Node, and Ruby on Rails vs JavaScript is the page that takes a position on it.

Conditions, because a number without them is not checkable. Apple M2 Max, 12 cores, macOS 26.5.1 arm64. Ruby 4.0.5 (2026-05-20 revision 64336ffd0e) with PRISM, Rails 8.1.4, turbo-rails 2.0.23, stimulus-rails 1.3.4, importmap-rails 2.2.3, propshaft 1.3.2, puma 8.0.2, pg 1.6.3, rack-cors 3.0.0. PostgreSQL 17.7 (Homebrew) on port 15432, loopback only. Node 26.4.0, npm 11.17.0, react 19.3.0, react-dom 19.3.0, vite 8.3.1, @vitejs/plugin-react 6.1.1. Browser measurements in Chrome 152.0.0.0. The Rails application ran RAILS_ENV=production with WEB_CONCURRENCY=1 and RAILS_MAX_THREADS=5 on 127.0.0.1:3500; the React application was built with vite build and served by vite preview on localhost:4173. 600 products, five categories, 120 rows each.

The same feature, written twice

One filterable table, two implementations. The Rails side is a controller and a template, and the format.json line in it is what the React version later consumes:

class ProductsController < ApplicationController
  CATEGORIES = %w[tools garden kitchen office outdoor].freeze

  def index
    @category = params[:category].presence
    @q = params[:q].presence
    @products = Product.order(:id)
    @products = @products.where(category: @category) if @category
    @products = @products.where("name ILIKE ?", "%#{@q}%") if @q

    respond_to do |format|
      format.html
      format.json { render json: @products.select(:id, :name, :sku, :price_cents, :stock, :category) }
    end
  end
end
<%= form_with url: products_path, method: :get, data: { turbo_frame: "catalog" } do |f| %>
  <%= f.text_field :q, value: @q, placeholder: "name" %>
  <%= f.select :category, ProductsController::CATEGORIES, { include_blank: "all" } %>
  <%= f.submit "Filter" %>
<% end %>

<%= turbo_frame_tag "catalog" do %>
  <p><%= @products.size %> products</p>
  <table>
    <thead><tr><th>Name</th><th>SKU</th><th>Price</th><th>Stock</th><th>Category</th></tr></thead>
    <tbody>
      <% @products.each do |product| %>
        <tr>
          <td><%= product.name %></td>
          <td><%= product.sku %></td>
          <td><%= number_to_currency(product.price_cents / 100.0) %></td>
          <td><%= product.stock %></td>
          <td><%= product.category %></td>
        </tr>
      <% end %>
    </tbody>
  </table>
<% end %>

That is 16 lines of Ruby and 25 lines of ERB, and not one line of JavaScript that I wrote. The React side keeps the controller exactly as it is and adds a second application:

export default function App() {
  const [products, setProducts] = useState([])
  const [q, setQ] = useState("")
  const [category, setCategory] = useState("")

  useEffect(() => {
    fetch(API).then((r) => r.json()).then(setProducts)
  }, [])

  const rows = useMemo(() => filterProducts(products, { q, category }), [products, q, category])
  ...
export const money = (cents) =>
  new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100)

export function filterProducts(products, { q, category }) {
  const needle = (q || "").toLowerCase()
  return products.filter((p) =>
    (!category || p.category === category) &&
    (!needle || p.name.toLowerCase().includes(needle))
  )
}

62 lines of JSX and JS across App.jsx, filter.js and Vite's generated main.jsx, plus 6 lines of rack-cors configuration on the Rails side that the next section explains. Both applications render the same first row: Widget 000 SKU-1000 $5.00 0 tools, read out of document.querySelector("tbody tr").innerText in Chrome on each page.

The setup cost of each one, measured rather than remembered. rails new left 81 files tracked by git and 105 gems in Gemfile.lock, and that includes the ORM, migrations, the mailer, the job framework and the test harness. npm create vite plus npm install left 18 source files and reported added 24 packages, and audited 25 packages in 7s, for 414 files and 53M in node_modules, and that includes none of the above because Rails is still doing all of it next door. vite build reported built in 4.06s cold and built in 111ms on a rebuild. bin/rails assets:precompile took 0.469s total and did no bundling at all: with importmaps it digests and copies files, so public/assets/turbo.min-9fd88cd5.js is byte for byte the gem's copy.

What is in the first response

curl runs no JavaScript, which makes it the honest stand-in for a crawler, a curl | grep in a support ticket, or a browser whose script request failed:

$ curl -s http://127.0.0.1:3500/products | grep -c '<tr>'
601
$ curl -s http://localhost:4173/ | grep -c 'Widget'
0
$ curl -s http://localhost:4173/
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>spa</title>
    <script type="module" crossorigin src="/assets/index-D6zUfuUA.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-nqMpL4T3.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

601 is 600 rows plus the header row. That difference is structural and everything else on this page is a cost attached to it. The mechanics of that split, and what rendering the rows actually costs inside ERB, are measured at length in Rails SSR vs CSR; this page is about what changes when the client half is React rather than a fetch and a for loop.

What the browser actually downloaded

Read out of performance.getEntriesByType("resource") in Chrome 152, first load, cache cold. The Hotwire page made 9 requests, 7 of them JavaScript:

turbo.min-9fd88cd5.js         105579
stimulus.min-4b1e420e.js       45657
stimulus-loading-1fc53fe7.js    3315
application-3affb389.js          218
index-ee64e1f1.js                272
hello_controller-708796bd.js     157
application-bfcdf840.js          157
application-8b441ae0.css         491
document (HTML)                98442

The React page made 4:

index-D6zUfuUA.js              68384
index-nqMpL4T3.css               810
products.json                      0
document (HTML)                  453

Two things in those lists are traps. The products.json size is 0 because the request is cross-origin and nobody sent Timing-Allow-Origin, so the browser zeroes the size fields; the file is 59074 bytes, from curl. And the bundle is 221084 bytes on disk, not 68384: vite preview answers Accept-Encoding: gzip with Content-Encoding: gzip, and puma does not. Rails 8.1.4 ships no Rack::Deflater in the default stack, which bin/rails middleware | grep -ci deflat returns 0 for, and a request carrying Accept-Encoding: gzip came back with content-length: 98442 and no Content-Encoding at all.

So compare them compressed by hand, which is what either one looks like behind anything that does compression. gzip -9: the HTML 7681 bytes, turbo.min.js 28657, stimulus.min.js 11063, stimulus-loading.js 1052 and application.js 155, which is 48608 bytes for the complete Hotwire page. On the other side, 453 bytes of shell, 68205 for the bundle, 810 for the CSS and 7561 for the JSON, which is 77029, and it takes two round trips before the first row exists. The 600-row HTML gzips to 7681 bytes and the JSON for the same 600 rows gzips to 7561, which is 120 bytes apart: the belief that HTML is the bloated format does not survive contact with a compressor.

The cost of the Hotwire side of this is 7 JavaScript requests over HTTP/1.1 against 1, because importmaps ship modules unbundled. On this loopback that cost nothing worth reporting. Behind a real connection with no HTTP/2 and no CDN it is 7 round trips, and that is the argument for putting something in front of puma rather than an argument for React.

The dead end: 200 in the log, zero rows on the page

The first React build that compiled cleanly rendered this, read out of document.body.innerText:

Catalog
all tools garden kitchen office outdoor

0 products

Name  SKU  Price  Stock  Category

document.querySelectorAll("tbody tr").length was 0. The console had exactly one entry, and it names nothing useful (the bundle hash is a build earlier than the one above, because the render counter that proves the one-render-per-change figure later on was not compiled in yet):

[EXCEPTION] (http://localhost:4173/assets/index-DH_c-954.js:8:51432)
TypeError: Failed to fetch

Meanwhile the Rails log was cheerful:

[0dc22c85-33b0-4f0c-b042-3abe8c8bec74] Started GET "/products.json" for 127.0.0.1 at 2026-09-27 17:03:50 +0200
[0dc22c85-33b0-4f0c-b042-3abe8c8bec74] Processing by ProductsController#index as JSON
[0dc22c85-33b0-4f0c-b042-3abe8c8bec74] Completed 200 OK in 6ms (Views: 5.6ms | ActiveRecord: 0.5ms (1 query, 0 cached) | GC: 0.0ms)

The request was sent, the query ran, all 600 products were serialised and the response came back 200. Then the browser threw the body away, because the response carried no Access-Control-Allow-Origin header and the page was on a different origin. TypeError: Failed to fetch is all the JavaScript gets to see; the explanatory line lives in Chrome's own network log, not in the exception. Six lines fixed it:

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "http://localhost:4173"
    resource "/products.json", headers: :any, methods: [:get]
  end
end

Two details about that gem are worth having before you rely on it. It does not block anything: a GET carrying Origin: http://evil.example still answered 200 with all 600 products in the body, just with no allow header on it, and the browser is the only thing enforcing the rule. And it takes over the preflight. Before rack-cors was installed, OPTIONS /products answered 404 with a 5201-byte Rails error page, since resources :products, only: :index routes no OPTIONS. Afterwards the same request answers 200 with content-length: 0 and access-control-max-age: 7200, and an origin nobody allowed gets a 200 with no headers rather than a refusal. This whole category of bug belongs to the React version and does not exist in the Hotwire one, where the HTML and the data have the same origin by construction.

Filtering 600 rows, and where the time goes

Server side first, since that half is stable. ab -n 300 -c 1 against the production server, one puma worker:

/products                                  98442 bytes   34.189 ms    29.25 req/s
/products  (Turbo-Frame: catalog)          96768 bytes   32.326 ms    30.93 req/s
/products?category=tools                   21673 bytes    8.370 ms   119.48 req/s
/products?category=tools (Turbo-Frame)     19999 bytes    8.552 ms   116.94 req/s
/products.json                             59074 bytes    7.564 ms   132.21 req/s
/products.json?category=tools              11671 bytes    2.472 ms   404.54 req/s

The frame line is the one that surprises people. Setting Turbo-Frame: catalog swapped in layouts/turbo_rails/frame.html.erb and saved 1674 bytes of head, and the server still rendered all 600 rows and the <h1> outside the frame. A frame narrows what the browser replaces, not what Ruby builds.

Client side, the filter itself is free. filterProducts over the 600-row array, 20000 iterations after 20000 warm-ups under node 26.4.0: 0.0025 ms filtering by category, 0.0140 ms by name, 0.0040 ms by both. Against 8.370 ms of server work for the same filter that is not a comparison, it is a rounding error, and it is the entire honest case for keeping the data in the browser.

Then React has to put the rows in the DOM, and that is where the money goes. Dispatching a change event on the select in Chrome and timing until the commit returned, 15 samples each: narrowing 600 rows to 120 took a median of 26.0 ms (21.0 to 33.7), and going back to 600 took a median of 110.1 ms (76.2 to 136.5). One render per state change, confirmed with a counter compiled into the production bundle, so React's StrictMode is not double-rendering here. The Turbo version of the same two transitions, timed from form.requestSubmit() to turbo:frame-load, ran 22.5 to 30.1 ms and 115.9 to 135.5 ms, with the fetch accounting for 10.2 to 13.4 ms and 37.6 to 49.1 ms of those.

Those two sets of numbers overlap, and I am not going to pretend one of them won. On a loopback with zero network latency, a full HTTP round trip to Rails plus Turbo's fragment swap costs about what React's reconciler costs to rebuild the same table from data it already had, because building 600 table rows dominates both. What would change it is the only variable this machine cannot supply: latency. Every Turbo interaction pays the round trip, so at a 50 ms RTT the Hotwire filter is 50 ms slower and the React filter is unchanged. That, and not rendering speed, is React's advantage on interaction.

Two caveats on the browser figures, and they are the reason those are ranges rather than a single number each. The measuring tab was backgrounded, so paint is excluded from every sample and the samples are noisy. And the two sides were not sampled identically: the React figures alternate the two transitions inside one loop, while the Turbo figures come from consecutive blocks of the same transition, because the alternating loop broke on the Turbo side. It produced a median of 86.8 ms for narrowing to 120 rows against 66.9 ms for widening to 600, the reverse of every other run and of the server numbers, since each measurement was absorbing the previous transition's leftover work. So read the two sets as orders of magnitude and not as a head to head. The Turbo blocks also had a high first sample each, 78.6 ms and 58.1 ms, left in the record rather than in the range.

Two implementations of one formatting rule

The price column is where the API boundary sends its bill. Rails formats it with number_to_currency(price_cents / 100.0); the React version receives price_cents and formats it with Intl.NumberFormat. I dumped all 600 formatted prices from both and diffed them, and they are identical, which is the good case and is still two implementations of one business rule in two languages.

Then they disagree. number_to_currency(-0.004) returns $0.00 and Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(-0.004) returns -$0.00. A refund line of less than half a cent renders with a minus sign on one stack and without it on the other, from code nobody would think to test. Multiply that by every date format, every pluralisation, every rounding rule and every permission check that the server already knows and the client has to be told, and you have the recurring cost of the second application. It is not the fetch call. It is that two codebases now hold opinions about the same domain.

The verdict

For the screens most products are made of, filter a table, submit a form, open a modal, page through a list, build it in Rails and let the server render. The Hotwire version of this feature was 41 lines with no build step, no second origin, no CORS configuration, no duplicated currency formatter, and it works with JavaScript broken. Reach for React when the screen holds state the server does not have and should not be told about on every keystroke: a canvas, an editor, a drag-heavy builder, anything that must keep working offline, anything where the interaction is the product.

What would change that verdict is latency plus interaction count. Every Turbo interaction is a round trip, and a screen where a user changes a filter forty times in a minute over a 150 ms connection is one where the data belongs in the browser. Count the round trips the screen actually needs before paying for the second application, not after.

What this page does not cover

Rendering React on the server, whether through Next.js or react-rails, is not measured here; every React figure above is a client-rendered production Vite build. Nothing here tests what a crawler does with the empty shell, only what curl receives. There is no measurement over a real network: every number was taken on 127.0.0.1, which is the condition most favourable to the Hotwire side. The React application was not tested with a router, a state library, TypeScript, code splitting or a component library, all of which move the bundle figure. And nothing here addresses React Native or a shared language across web and mobile, which is a real argument for a JavaScript client and one this experiment cannot say anything about.

#rails #hotwire #comparison

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.