docs/pro/streaming-ssr.md
React on Rails Pro supports streaming server rendering using React 18/19's renderToPipeableStream API. Instead of waiting for the entire page to render before sending any HTML, streaming SSR sends HTML to the browser progressively as each part of the page becomes ready.
Route map: Start at React on Rails Pro if you're choosing a path. This page is the canonical streaming SSR overview; for the technical implementation guide, see Streaming Server Rendering.
Traditional SSR renders the full page on the server, then sends the complete HTML in one response. This means the user sees nothing until the slowest component finishes rendering. Streaming SSR changes this:
<Suspense> boundaries define which parts can stream independentlyrenderToPipeableStream to render React components<Suspense> boundary resolves (e.g., an async data fetch completes), the rendered HTML chunk is streamed to the browserEnsure you're using React 19 in your package.json:
"dependencies": {
"react": "19.0.4",
"react-dom": "19.0.4"
}
Note: Use the latest React 19 patch release that is compatible with your app and tooling.
Create async React components that return a promise. Use the Suspense component to render a fallback UI while the component is loading.
// app/javascript/components/MyStreamingComponent.jsx
import React, { Suspense } from 'react';
const fetchData = async () => {
// Simulate API call
const response = await fetch('api/endpoint');
return response.json();
};
const MyStreamingComponent = () => {
return (
<>
<header>
<h1>Streaming Server Rendering</h1>
</header>
<Suspense fallback={<div>Loading...</div>}>
<SlowDataComponent />
</Suspense>
</>
);
};
const SlowDataComponent = async () => {
const data = await fetchData();
return <div>{data}</div>;
};
export default MyStreamingComponent;
Note: The
async () => { ... }function component pattern (SlowDataComponentabove) is a React Server Components feature. If you are using streaming SSR without RSC, use a data-fetching library (such as React Query or SWR) with<Suspense>instead. For RSC-based streaming (which does support async components), see the RSC tutorial.
// app/javascript/packs/registration.jsx
import ReactOnRails from 'react-on-rails';
import MyStreamingComponent from '../components/MyStreamingComponent';
ReactOnRails.register({ MyStreamingComponent });
<!-- app/views/example/show.html.erb -->
<%= stream_react_component('MyStreamingComponent', props: { greeting: 'Hello, Streaming World!' }) %>
<footer>
<p>Footer content</p>
</footer>
stream_view_containing_react_components HelperEnsure you have a controller that renders the view containing the React components. The controller must include the ReactOnRails::Controller and ReactOnRailsPro::Stream modules.
# app/controllers/example_controller.rb
class ExampleController < ApplicationController
include ReactOnRails::Controller
include ReactOnRailsPro::Stream
# Note: ActionController::Live is already mixed in by ReactOnRailsPro::Stream,
# but you can include it explicitly if you prefer.
def show
stream_view_containing_react_components(template: 'example/show')
end
end
You can test your application by running rails server and navigating to the appropriate route.
When a user visits the page, they'll experience the following sequence:
The initial HTML shell is sent immediately, including:
<h1> and footer)As the React component processes and suspense boundaries resolve:
For example, with our MyStreamingComponent, the sequence might be:
<header>
<h1>Streaming Server Rendering</h1>
</header>
<template id="s0">
<div>Loading...</div>
</template>
<footer>
<p>Footer content</p>
</footer>
<template hidden id="b0">
<div>[Fetched data]</div>
</template>
<script>
// This implementation is slightly simplified
document.getElementById('s0').replaceChildren(document.getElementById('b0'));
</script>
Streaming responses use ActionController::Live, which writes chunks to a SizedQueue (a destructive, non-idempotent data structure). Standard Rack compression middleware (Rack::Deflater, Rack::Brotli) works correctly with streaming by default — each chunk is compressed and flushed immediately, preserving low TTFB.
However, if you pass an :if condition that calls body.each to check the response size, streaming responses will deadlock. The :if callback destructively consumes all chunks from the queue, leaving nothing for the compressor to read.
# BAD — causes deadlocks with streaming responses
config.middleware.use Rack::Deflater, if: lambda { |*, body|
sum = 0
body.each { |i| sum += i.length } # destructive — drains the queue
sum > 512
}
The Rack SPEC states that each must only be called once and middleware must not call each directly unless the body responds to to_ary. Streaming bodies explicitly do not support to_ary.
Correct pattern — check to_ary before iterating:
config.middleware.use Rack::Deflater, if: lambda { |*, body|
# Streaming bodies don't support to_ary — always compress them.
# Rack::Deflater handles streaming correctly with sync flush per chunk.
return true unless body.respond_to?(:to_ary)
body.to_ary.sum(&:bytesize) > 512
}
The same applies to Rack::Brotli or any middleware that accepts an :if callback.
Streaming SSR is fully compatible with React 19's native metadata tags. You can render <title>, <meta>, and <link> anywhere in your component tree — including inside async components within Suspense boundaries — and React will hoist them into the document <head>.
This is a significant advantage over react-helmet, which requires renderToString and is incompatible with streaming. For details, see React 19 Native Metadata.
Streaming SSR is particularly valuable in specific scenarios. Here's when to consider it:
Data-Heavy Pages
Progressive Enhancement
Large, Complex Applications
Component Structure
// Good: Independent sections that can stream separately
<Layout>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<MainContentSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</Layout>
// Bad: Everything wrapped in a single Suspense boundary
<Suspense fallback={<FullPageSkeleton />}>
<Header />
<MainContent />
<Sidebar />
</Suspense>
Data Loading Strategy
IMPORTANT: When using streaming server rendering, you should NOT use defer: true for your JavaScript pack tags. Here's why:
Deferred scripts (defer: true) only execute after the entire HTML document has finished parsing and streaming. This defeats the key benefit of React's Selective Hydration feature, which allows streamed components to hydrate as soon as they arrive—even while other parts of the page are still streaming.
Example Problem:
<!-- ❌ BAD: This delays hydration for ALL streamed components -->
<%= javascript_pack_tag('client-bundle', defer: true) %>
With defer: true, your streamed components will:
For Pages WITH Streaming Components:
<!-- ✅ GOOD: No defer - allows Selective Hydration to work -->
<%= javascript_pack_tag('client-bundle', 'data-turbo-track': 'reload', defer: false) %>
<!-- ✅ BEST: Use async for even faster hydration (requires Shakapacker ≥ 8.2.0) -->
<%= javascript_pack_tag('client-bundle', 'data-turbo-track': 'reload', async: true) %>
For Pages WITHOUT Streaming Components:
With Shakapacker ≥ 8.2.0, async: true is recommended even for non-streaming pages to improve Time to Interactive (TTI):
<!-- ✅ RECOMMENDED: Use async with immediate_hydration for optimal performance -->
<%= javascript_pack_tag('client-bundle', 'data-turbo-track': 'reload', async: true) %>
Note: async: true with the immediate_hydration feature allows components to hydrate during page load, improving TTI even without streaming. See the Immediate Hydration section below for configuration details.
⚠️ Important: Redux Shared Store Caveat
If you are using Redux shared stores with the redux_store helper and inline script registration (registering components in view templates with <script>ReactOnRails.register({ MyComponent })</script>), you must use defer: true instead of async: true:
<!-- ⚠️ REQUIRED for Redux shared stores with inline registration -->
<%= javascript_pack_tag('client-bundle', 'data-turbo-track': 'reload', defer: true) %>
Why? With async: true, the bundle executes immediately upon download, potentially before inline <script> tags in the HTML execute. This causes component registration failures when React on Rails tries to hydrate the component.
Solutions:
defer: true - Ensures proper execution order (inline scripts run before bundle)getOrWaitForStore and getOrWaitForStoreGenerator can handle async loading with inline registrationSee the Redux Store API documentation for more details on Redux shared stores.
With Shakapacker ≥ 8.2.0, using async: true provides the best performance:
defer: false for streaming pagesasync: true for all pages (streaming and non-streaming)immediate_hydration: Configure for optimal Time to Interactive (see section below)React on Rails Pro automatically enables the immediate_hydration feature, which allows components to hydrate during the page loading state (before DOMContentLoaded). This works optimally with async: true scripts.
Benefits of immediate_hydration with async: true:
Note: The immediate_hydration feature requires a React on Rails Pro license. It is enabled automatically — no configuration needed.
Component-Level Control:
You can disable immediate hydration for specific components:
<%= react_component('MyComponent', props: {}, immediate_hydration: false) %>
generated_component_packs_loading_strategy Option:
This configuration option sets the default loading strategy for auto-generated component packs:
:async (recommended for Shakapacker ≥ 8.2.0) - Scripts load asynchronously:defer - Scripts defer until page load completes:sync - Scripts load synchronously (blocks page rendering)ReactOnRails.configure do |config|
config.generated_component_packs_loading_strategy = :async
end