Skip to main content

Overview

COSMOS RSC supports streaming SSR, which allows the server to send HTML to the browser incrementally as components finish rendering. This keeps your page interactive even while slow data is loading.

How streaming works

With streaming SSR:
  1. Server sends the initial HTML shell immediately
  2. Client displays the shell and shows loading states
  3. Server streams in content as components finish rendering
  4. Client progressively updates the page without blocking

Using Suspense

Wrap async components in Suspense boundaries to enable streaming:
app/pages/features/streaming.js
import { Suspense } from 'react';

// Async component that takes time to load
async function SlowData({ delay, label }) {
  await new Promise((resolve) => setTimeout(resolve, delay));
  return (
    <div className='rounded bg-white p-4 shadow'>
      <h3 className='font-medium'>{label}</h3>
      <p>Data loaded after {delay}ms</p>
    </div>
  );
}

// Loading fallback
function LoadingCard() {
  return (
    <div className='animate-pulse rounded bg-gray-50 p-4 shadow'>
      <div className='mb-2 h-4 w-1/4 rounded bg-gray-200'></div>
      <div className='h-4 w-3/4 rounded bg-gray-200'></div>
    </div>
  );
}

export default function StreamingDemo() {
  return (
    <div>
      <h1>Streaming Demo</h1>
      
      <div className='grid gap-4'>
        <Suspense fallback={<LoadingCard />}>
          <SlowData delay={1000} label='Fast Component' />
        </Suspense>
        
        <Suspense fallback={<LoadingCard />}>
          <SlowData delay={3000} label='Medium Component' />
        </Suspense>
        
        <Suspense fallback={<LoadingCard />}>
          <SlowData delay={5000} label='Slow Component' />
        </Suspense>
      </div>
    </div>
  );
}
Each Suspense boundary:
  • Shows the fallback immediately
  • Streams the real content when ready
  • Updates without blocking other components
Without Suspense, the page would wait for all components to finish before sending any HTML.

Streaming implementation

COSMOS RSC implements streaming using React DOM Server’s renderToPipeableStream:
core/server/lib/fizz-worker.js
const { renderToPipeableStream } = require('react-dom/server');
const { injectRSCPayload } = require('../../rsc-html-stream/server');

const htmlStream = renderToPipeableStream(
  createElement(SSRApp, {
    initialState: { tree },
    rootLayout,
  }),
  {
    formState,
    bootstrapScripts: ['/client.js'],
    onShellReady: () => {
      // Start streaming as soon as shell is ready
      htmlStream
        .pipe(injectRSCPayload(payloadConsumerRSCStream))
        .pipe(writableStream);
    },
  }
);
Key points:
  • onShellReady fires when the initial HTML is ready
  • Content streams in as Suspense boundaries resolve
  • RSC payload is injected inline as content arrives

RSC payload injection

The streaming implementation injects the RSC payload into the HTML:
core/rsc-html-stream/server.js
function injectRSCPayload(rscStream) {
  const transform = new Transform({
    async flush(callback) {
      await flightDataPromise;
      // Write any buffered chunks
      this.push(encoder.encode(trailer));
      callback();
    },
  });
  
  return transform;
}

async function writeRSCStream(rscStream, transform) {
  for await (const chunk of rscStream) {
    writeChunk(JSON.stringify(decoder.decode(chunk)), transform);
  }
}

function writeChunk(chunk, transform) {
  transform.push(
    encoder.encode(
      `<script>${escapeScript(
        `(self.__RSC_PAYLOAD||=[]).push(${chunk})`
      )}</script>`
    )
  );
}
This embeds RSC data as inline script tags throughout the HTML stream.

Client-side streaming

The client reads the streamed RSC payload:
core/rsc-html-stream/client.js
export const rscStream = new ReadableStream({
  start(controller) {
    let handleChunk = (chunk) => {
      if (typeof chunk === 'string') {
        controller.enqueue(encoder.encode(chunk));
      } else {
        controller.enqueue(chunk);
      }
    };
    
    // Handle inline script chunks
    window.__RSC_PAYLOAD ||= [];
    window.__RSC_PAYLOAD.forEach(handleChunk);
    window.__RSC_PAYLOAD.push = (chunk) => {
      handleChunk(chunk);
    };
  },
});
As inline scripts execute, they push chunks to the stream which React consumes.

Nested Suspense boundaries

You can nest Suspense boundaries for fine-grained loading states:
export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* Top-level suspense */}
      <Suspense fallback={<DashboardSkeleton />}>
        <Dashboard>
          {/* Nested suspense for slower data */}
          <Suspense fallback={<ChartLoading />}>
            <ExpensiveChart />
          </Suspense>
          
          {/* Independent suspense boundary */}
          <Suspense fallback={<TableLoading />}>
            <DataTable />
          </Suspense>
        </Dashboard>
      </Suspense>
    </div>
  );
}
This creates a hierarchy:
  • Page shell loads first
  • Dashboard shell loads next
  • Chart and table stream independently

Parallel data fetching

Suspense boundaries enable parallel data fetching:
// ❌ Sequential - slow
export default async function SlowPage() {
  const user = await fetchUser();
  const posts = await fetchPosts();
  const comments = await fetchComments();
  return <div>{/* ... */}</div>;
}

// ✅ Parallel - fast
export default function FastPage() {
  return (
    <div>
      <Suspense fallback={<LoadingUser />}>
        <User />
      </Suspense>
      <Suspense fallback={<LoadingPosts />}>
        <Posts />
      </Suspense>
      <Suspense fallback={<LoadingComments />}>
        <Comments />
      </Suspense>
    </div>
  );
}

async function User() {
  const user = await fetchUser();
  return <div>{user.name}</div>;
}

async function Posts() {
  const posts = await fetchPosts();
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

async function Comments() {
  const comments = await fetchComments();
  return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
All three components fetch data in parallel and stream independently.

Loading skeletons

Create skeleton components that match your content structure:
function ProductCardSkeleton() {
  return (
    <div className='rounded border p-4'>
      <div className='mb-4 h-48 animate-pulse bg-gray-200' />
      <div className='mb-2 h-6 animate-pulse bg-gray-200' />
      <div className='h-4 w-2/3 animate-pulse bg-gray-200' />
    </div>
  );
}

function ProductCard({ productId }) {
  return (
    <Suspense fallback={<ProductCardSkeleton />}>
      <ProductDetails productId={productId} />
    </Suspense>
  );
}

async function ProductDetails({ productId }) {
  const product = await fetchProduct(productId);
  return (
    <div className='rounded border p-4'>
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
    </div>
  );
}

Streaming benefits

Streaming provides several advantages:

Faster Time to First Byte

Server sends initial HTML immediately without waiting for all data

Progressive Enhancement

Page becomes interactive before all content loads

Better Perceived Performance

Users see loading states instead of blank screens

Parallel Data Fetching

Multiple components fetch data simultaneously

Worker thread architecture

COSMOS RSC uses worker threads to enable concurrent HTML rendering:
core/server/index.js
const { MessageChannel, Worker } = require('worker_threads');

// Create worker on server startup
const fizzWorker = new Worker(FIZZ_WORKER_PATH, {
  execArgv: ['--conditions', 'default'],
});

async function requestHandler(req, res) {
  // Generate RSC stream
  const rscStream = renderToPipeableStream(payload, webpackMap);
  
  // Set up message channel to worker
  const { port1, port2 } = new MessageChannel();
  fizzWorker.postMessage({ port: port2 }, [port2]);
  
  // Pipe RSC data to worker
  rscStream.on('data', (data) => {
    port1.postMessage({ type: 'data', data });
  });
  
  // Receive HTML from worker
  port1.on('message', (message) => {
    if (message.type === 'data') {
      res.write(message.data);
    } else if (message.type === 'end') {
      res.end();
    }
  });
}
This architecture:
  • Keeps the main thread responsive
  • Enables concurrent request handling
  • Isolates HTML rendering from RSC rendering

Next steps

Server Actions

Learn about server actions for forms

Architecture

Understand the full architecture

Build docs developers (and LLMs) love