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.
Comments
No comments yet. Be the first.