React on Rails Performance Demo
Architecture Guide

How RSC Works
With Rails

A visual guide to how React Server Components stream data from your Rails backend to the browser — step by step.

Rails Controller + View Node Renderer RSC Components React Flight RSC Payload Streaming SSR HTML Chunks Browser Progressive UI emit.call() Flight protocol renderToStream progressive
Step 1

Rails Prepares the Data

Your controller fetches lightweight metadata as sync props. Heavy data is deferred to the streaming block — it runs concurrently in a background thread while the page shell streams.

1a
Controller — fast sync props
products_controller.rb
def show_rsc
  @product = find_product

  # Only lightweight metadata as sync props
  # (~2KB instead of ~28KB)
  @data = serialize(@product)
    .except(:description, :specs)

  stream_view_containing_react_components(
    template: "products/show_rsc"
  )
end
1b
View — background streaming
show_rsc.html.erb
<%= stream_react_component_with_async_props(
  "ProductPageRSC",
  props: { product: @data }
) do |emit|
  # Background thread — streams while
  # browser already renders the shell
  emit.call("details", @product.details)
  emit.call("reviews", @product.reviews)
  emit.call("related", @product.related)
end %>

What happens at this step

1

Controller fetches the product and serializes only the essential metadata (name, price, images) as sync props. Heavy data (description, reviews, related) is excluded.

2

stream_view_containing_react_components starts an HTTP streaming response — the browser receives the first bytes immediately.

3

The do |emit| block runs in a background thread. Each emit.call sends a named data chunk to the Node renderer as soon as its query completes.

Step 2

React Renders on the Server

Server Components receive sync props instantly and render the page shell. Async components await streamed data from Rails and render as it arrives.

2a
Page shell — renders instantly
ProductPageRSC.tsx
// Server component — no 'use client'
export default function ProductPageRSC(
  { product, getReactOnRailsAsyncProp }
) {
  return (
    <>
      <Hero product={product} />
      <Suspense fallback={<Skeleton />}>
        <AsyncDetails {...props} />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <AsyncReviews {...props} />
      </Suspense>
    </>
  );
}
2b
Async component — awaits data
AsyncReviews.tsx
// Server component — async
// Runs on server, 0 JS to client
export default async function AsyncReviews(
  { getReactOnRailsAsyncProp: get }
) {
  // Resolves when Rails calls
  // emit.call("reviews", data)
  const { reviews } = await get('reviews');

  return <ReviewList reviews={reviews} />;
}

// Suspense shows <Skeleton /> until
// this component's await resolves

What happens at this step

1

The Node renderer receives sync props and starts rendering ProductPageRSC. The Hero section renders instantly with product metadata.

2

React hits <Suspense> boundaries. Each async component calls getReactOnRailsAsyncProp which returns a Promise that resolves when Rails emits the data.

3

React renders the skeleton fallbacks for each Suspense boundary and streams the shell HTML to the browser. The user sees the page structure immediately.

Step 3

HTML Streams to the Browser

As each emit.call fires in Rails, the corresponding Suspense boundary resolves. React streams the real content as an HTML chunk that replaces the skeleton in-place.

0ms 100ms 500ms 1.5s RAILS Background thread: emit.call("details") ... emit.call("reviews") ... emit.call("related") NODE Shell Details Reviews Related BROWSER Hero Shell only Hero Details + Details Hero Details Reviews + Reviews Hero Details Reviews Related Complete

The key insight

Each emit.call in Rails directly resolves a getReactOnRailsAsyncProp Promise in React. The prop name must match exactly:

emit.call("reviews", data) await get("reviews")
Comparison

Three Rendering Modes

React on Rails Pro supports all three approaches. Choose per page, migrate incrementally.

V1

Full SSR

All data fetched on server. Full HTML returned at once. All JS shipped to client for hydration.

Entire page waits for slowest query
All libraries shipped to client
Full hydration required
react_component("...", prerender: true)
V2

Client + Loadable

Code-split with @loadable/component. SSR critical parts, lazy-load the rest on client.

Lazy content loads after hydration
JS deferred but still shipped
Complex setup (5 packages)
loadable(() => import("...")) + ChunkExtractor
V3

RSC Streaming

RECOMMENDED

Server components stream progressively. Only interactive JS ships to client. Zero-cost rendering.

Sections stream independently
Server-only code: 0 KB to client
Minimal setup: just 'use client'
stream_react_component_with_async_props
Architecture

Server vs Client Boundary

Most of your component tree stays on the server. Only interactive islands — buttons, filters, forms — ship JavaScript to the browser.

Server Component (0 KB to client) Client Component (ships JS) ProductPageRSC ProductHero AsyncDetails AsyncReviews ImageGallery 'use client' AddToCart 'use client' Description Specs ReviewList Server: 7 components (0 KB) Client: 2 (~8 KB)
78%
Server-Only

7 of 9 components never ship JS to the browser

~8 KB
Client JS

Only ImageGallery and AddToCart ship interactive code

0 KB
Heavy Libraries

Markdown, charts, date formatting stay server-side

Start Building with RSC

See the performance difference yourself. React on Rails Pro makes RSC work seamlessly with your Rails app.