Performance Benchmarks
This page covers the performance characteristics of React on Rails across different rendering strategies, with data from real-world deployments and comparative benchmarks.
SSR Performance: ExecJS vs Node Renderer
The default ExecJS renderer evaluates JavaScript synchronously inside a single-threaded pool. The Node Renderer (React on Rails Pro) runs a dedicated Node.js master process with worker processes (via cluster.fork()), providing dramatically better throughput.
Key Differences
| Metric | ExecJS (mini_racer) | ExecJS (Node.js runtime) | Node Renderer (Pro) |
|---|---|---|---|
| Architecture | V8 isolate in Ruby process | New process per eval call | Persistent Node.js workers |
| Concurrency (MRI) | Single-threaded (pool size 1) | Single-threaded (pool size 1) | Multi-worker |
| Async support | None | None | Full (Promises, timers) |
| Streaming SSR | Not supported | Not supported | Supported |
| RSC support | Not supported | Not supported | Supported |
| Typical speedup | Baseline | Comparable | 3-10x over ExecJS |
The Node Renderer's persistent process supports full async rendering and multi-worker concurrency, which are the primary sources of the performance difference. Popmenu reported a 73% decrease in response times after switching to Pro. ExecJS is limited to synchronous rendering within a single-threaded pool, so the gap widens for pages with many async data sources or large component trees.
Bundle Splitting Impact
React on Rails supports code splitting (Pro feature) to reduce the amount of JavaScript sent to the browser. The impact depends on your application's structure:
Client Bundle Size
Code splitting with dynamic import() breaks your application into smaller chunks loaded on demand:
- Without splitting: A single bundle contains all components, even those not needed on the current page
- With splitting: Only the components required for the current route are loaded initially; others load on navigation
For applications with many routes and components, code splitting can substantially reduce initial bundle size. The actual reduction depends on how much code is shared across routes — apps with mostly independent route content typically see larger gains.
Server Bundle Size
Server bundles are not typically split because the server can load the full bundle once at startup. However, with React Server Components, server-only dependencies are excluded from the client bundle entirely, which compounds the benefits of code splitting.
Streaming SSR Benefits
Streaming SSR (Pro feature) uses React's renderToPipeableStream to send HTML progressively as components resolve:
Time to First Byte (TTFB)
| Rendering Strategy | TTFB | Full Page Load |
|---|---|---|
| Client-side only | Fast (empty shell) | Slow (fetch + render) |
Traditional SSR (renderToString) | Slow (waits for all data) | Fast (complete HTML) |
| Streaming SSR | Fast (shell immediately) | Progressive (chunks arrive) |
Streaming SSR provides the best of both approaches: the browser receives the initial HTML shell immediately (fast TTFB) while data-dependent sections stream in as they resolve. This is especially valuable for pages with multiple independent data sources.
Selective Hydration
With streaming SSR and React 18+, components can hydrate independently as their JavaScript loads:
- Navigation can become interactive while main content is still streaming
- User interactions automatically prioritize hydration of the clicked component
- No single "hydration wall" where the entire page freezes
Note: By default, React on Rails uses defer scripts which delay all hydration until the page finishes streaming. To enable selective hydration, configure your initializer:
config.generated_component_packs_loading_strategy = :async
See Selective Hydration in Streamed Components for complete details.
React Server Components Impact
React Server Components (Pro feature) provide additional performance benefits on top of streaming SSR:
Client Bundle Reduction
Server components and their dependencies are excluded from the client bundle. In practice, this means:
// These imports stay server-side — zero client cost
import { format } from 'date-fns'; // ~30KB
import { marked } from 'marked'; // ~35KB
import numeral from 'numeral'; // ~25KB
Applications that use heavy formatting, parsing, or data-processing libraries on the server side see the largest gains. Frigade reported a 62% reduction in client-side bundle size after migrating to RSC.
No Hydration for Server Components
Server components produce HTML that does not need hydration — they have no client-side JavaScript. Only client components (those with 'use client') require hydration. This reduces Total Blocking Time and improves Time to Interactive.
Real-World Results
This section covers a non-production local directional benchmark first, then a production case study. For validated, at-scale results, see the Production Case Study: Popmenu below.
Non-Production Local Directional Benchmark: Gumroad-Style RSC Demo (April 2026)
The Gumroad-style RSC benchmark demo
is a public ShakaCode comparison repo modeled after a creator-dashboard surface with product listings and sales metrics,
not an official Gumroad integration. The benchmark methodology and earlier-run artifacts are checked in as
docs/performance-findings.md
on the active demo PR (shakacode/react-on-rails-demo-gumroad-rsc#10);
the April 30, 2026 absolute timings reported below are from a more recent local run that has not yet landed in that
artifact (Issue 3263 tracks the missing distribution and
source artifacts). It measures the same reduced presenter data and outer layout across two routes. The comparison
changes three axes at once (RSC, the Pro Node renderer, and SSR), so the deltas cannot be attributed to any single
factor. The routes are:
- Inertia-style control:
/dashboard/inertia_demo(uses the actualinertia_railsgem; no Pro renderer or SSR) - React on Rails Pro + React Server Components:
/dashboard/rsc_demo
Both routes use the same Shakapacker with Rspack page-asset build, so this is a route-level comparison rather than a bundler comparison. It also does not isolate a renderer-only baseline: the Inertia route has no React on Rails Pro renderer or SSR, while the RSC route uses the Pro Node renderer. Treat the deltas as the combined route-level effect; see the SSR Performance table for the renderer baseline.
The April 30, 2026 local benchmark used eight strictly alternating measured runs between the Inertia and RSC routes (Inertia, RSC, Inertia, RSC, and so on), four per route. Before each of the eight measured runs, the harness sent one warmup request to the route being measured.
Conditions:
- Compiled page assets from the same Shakapacker with Rspack configuration for both routes
- Compiled RSC demo bundles
- Rails server without the Shakapacker dev server running
- Dedicated React on Rails Pro Node renderer on
RENDERER_PORT=3800 - Chrome 147 with matching ChromeDriver 147
The original artifact does not yet publish RAILS_ENV, so the absolute timing values may include
RAILS_ENV=development overhead (no eager loading, active code reloader, no asset caching). It also does not publish
browser cache behavior between measured runs, hardware/OS, or Ruby/Node/Rails versions. Unknown browser cache state
between measured runs affects repeatability. The single warmup request before each measured run may also be
insufficient for the Pro Node renderer worker pool to reach JIT and RSC-payload-compilation steady state, which is
more likely to make the RSC route look slower than its steady-state performance than to inflate its advantage.
Issue 3253 tracks the missing environment metadata. Until
that is resolved, treat these numbers as directional signals rather than a stable baseline.
The median results showed this directional signal. The source artifact's navigation-duration metric comes from its
Playwright harness and may differ from PerformanceNavigationTiming.duration.
| Source | Metric | Inertia demo | RSC demo | Delta % (negative = RSC faster) |
|---|---|---|---|---|
| Browser | Navigation duration | 775ms | 607ms | -21.7% |
| Browser | Largest Contentful Paint | 794ms | 634ms | -20.2% |
| Browser | responseEnd | 645ms | 589ms | -8.7% |
| Rails | Controller action_total (Rails wall time) [†] | 347ms | 339ms | -2.3% |
[†] action_total scope is unconfirmed; do not use it to infer the server-rendering split — see the paragraph below.
action_total is the Rails wall-time field from the raw benchmark artifact, not a browser Performance API metric. The
artifact does not yet publish enough logger or extraction-script context to confirm whether it is the full
process_action duration including rendering or a narrower controller-action field, so do not use it to infer the
server-rendering split. Because the Pro Node renderer runs in a separate OS process, Rails wall time may also exclude
RSC rendering cost that the Inertia control keeps in-process, so the two action_total values may not measure identical
scopes of work. Issue 3263 tracks the missing distribution
and source artifacts.
The navigation-duration gain (-21.7%) was larger than the responseEnd gain (-8.7%), which is consistent with the RSC
route delivering fully server-rendered HTML — the browser has minimal client-side hydration work after responseEnd,
while the Inertia control must hydrate the React component tree on the client. Because the navigation-duration value
comes from the source artifact's Playwright harness rather than PerformanceNavigationTiming.duration, the two metrics
are not from the same timing source and a direct navigation duration - responseEnd subtraction is not reported here.
The page-specific script request count was 6 for the Inertia demo and 1 for the RSC demo, recorded as Chrome DevTools
Network panel Script-type requests after loading each route. This is a fixed post-load observation, not a per-run
timing median or statistical sample. Fewer requests do not necessarily imply a smaller browser payload: the RSC route
carries runtime, Flight payload, and RSC-specific bundle costs that the Inertia control does not, so total transfer size
is the meaningful network-cost metric and is not reported here. The raw request-count difference is noted for
completeness only. See
Issue 3259.
- The linked
performance-findings.mdartifact reflects an earlier run; the April 30 timings shown above will be added there in a follow-up, and distribution/variance artifacts are still pending. See Issue 3263. - All timing values are medians from the raw benchmark artifact values (n=4 per route); sample size is too small to establish statistical significance.
- The
action_total-2.3% delta is likely within expected variance at n=4.
Worst-case responseEnd counter-signal
| Metric | Inertia demo | RSC demo | Delta % (negative = RSC faster) |
|---|---|---|---|
Worst-case responseEnd (max n=4) | 731ms | 768ms | +5.1% |
With only four samples, p95 is the maximum observed value by definition, not an independently estimated tail
percentile; this metric is therefore reported as "worst-case (max n=4)" here. It shows a +5.1% RSC regression on
worst-case responseEnd (high variance is expected at n=4), indicating the Inertia control had a faster worst-case
responseEnd than the RSC route.
Use these numbers as a case-study signal, not a universal performance claim. The RSC route combines RSC, the Pro Node
renderer, and SSR, while the Inertia control has none of those three factors. With that caveat, the RSC route showed
faster median navigation duration and LCP on the measured routes. The worst-case responseEnd counter-signal favored
the Inertia control. A stable deployed repeat, renderer-internal timing, environment metadata, and distribution artifacts
are still required before making stronger production-performance claims.
See Issue 3128 and Issue 3144 for the ongoing tracking discussion.
Production Case Study: Popmenu
Popmenu, a restaurant platform serving tens of millions of SSR requests daily, adopted React on Rails Pro and reported:
- 73% decrease in average response times
- 20-25% lower Heroku hosting costs
- Stable performance under high traffic with the Node Renderer's worker pool
See the full case study.
Measuring Your Own Performance
Key Metrics to Track
- Time to First Byte (TTFB): How quickly the server begins sending HTML
- Largest Contentful Paint (LCP): When the main content becomes visible
- Total Blocking Time (TBT): Time the main thread is blocked during page load
- Client bundle size: Total JavaScript downloaded by the browser
- Server render time: Time spent in the SSR process (not logged by default — measure wall clock time in Ruby around the render call; on Pro, enable
config.tracing = trueinconfig/initializers/react_on_rails_pro.rbto log render timings)
Tools
- Chrome DevTools Performance tab: Profile page load and hydration timing
- Lighthouse: Automated performance scoring with LCP, TBT, and other Core Web Vitals
webpack-bundle-analyzer: Visualize bundle composition and identify large dependencies- Rails server logs: Server-side console messages replayed to
Rails.loggerwhenconfig.logging_on_server = true - Node Renderer logs: Renderer lifecycle and error details controlled by
RENDERER_LOG_LEVEL(Pro)
Related Documentation
- ExecJS Limitations — constraints of the default rendering engine
- Streaming Server Rendering — setup and best practices
- Code Splitting — route-based bundle splitting
- Node Renderer Basics — Pro Node.js renderer setup
- OSS vs Pro — feature comparison
- React Server Components — RSC overview and guides