Streaming SSR
This guide builds on SSR with Data Loading. Make sure you understand load() and basic SSR setup first.
Why Streaming?
Section titled “Why Streaming?”With renderToStringAsync, the server waits for all data before sending anything. That’s fine when data loads quickly, but if one API takes 3 seconds, users stare at a blank screen for 3 seconds.
Streaming SSR sends the page shell immediately, then streams data chunks as each load() boundary resolves. Users see content faster, especially when some data sources are slow.
The Stream Writer
Section titled “The Stream Writer”Streaming needs a way to send data from server to client as it becomes available. createStreamWriter generates the JavaScript for this:
import { createStreamWriter } from '@rimitive/ssr/server';
const stream = createStreamWriter('__APP_STREAM__');
// Bootstrap code goes in <head> - sets up the receiverstream.bootstrapCode();// → "window.__APP_STREAM__=(function(){...})();"
// Chunk code is sent as data resolves - pushes to the receiverstream.chunkCode('stats', { users: 100 });// → "__APP_STREAM__.push(\"stats\",{\"users\":100});"The receiver queues data until the client calls connectStream(), then forwards chunks to the loader.
Server Setup
Section titled “Server Setup”Use renderToStream instead of renderToStringAsync:
import { createServer } from 'node:http';import { createParse5Adapter, renderToStream, createStreamWriter,} from '@rimitive/ssr/server';import type { RefSpec } from '@rimitive/view/types';import { createService } from './service.js';import { App } from './App.js';
const STREAM_KEY = '__APP_STREAM__';const stream = createStreamWriter(STREAM_KEY);
const server = createServer(async (req, res) => { const { adapter, serialize, insertFragmentMarkers } = createParse5Adapter();
// Create service with streaming callback const service = createService(adapter, { onResolve: (id, data) => { // Stream each data chunk as it resolves res.write(`<script>${stream.chunkCode(id, data)}</script>`); }, });
// Render with pending states const { initialHtml, done, pendingCount } = renderToStream( App(service), { mount: (spec: RefSpec<unknown>) => spec.create(service), serialize, insertFragmentMarkers, } );
// Start response immediately res.writeHead(200, { 'Content-Type': 'text/html' });
// Write document head with bootstrap script res.write(`<!DOCTYPE html><html><head> <script>${stream.bootstrapCode()}</script></head><body>`);
// Write initial HTML (with loading states) res.write(`<div class="app">${initialHtml}</div>`);
// Write client script res.write('<script src="/client.js"></script>');
// Wait for all data to stream await done;
// Close document res.write('</body></html>'); res.end();});
server.listen(3000);Service with Streaming
Section titled “Service with Streaming”Add onResolve to your service options and pass it through to createLoaderModule:
// service.ts - add to your existing ServiceOptionsexport type ServiceOptions = { hydrationData?: Record<string, unknown>; onResolve?: (id: string, data: unknown) => void; // 👈 Add this};
// Pass it through to the loadercreateLoaderModule({ initialData: options?.hydrationData, onResolve: options?.onResolve, // 👈 Add this})See the SSR with Data Loading service setup for the full example.
Client Setup
Section titled “Client Setup”The client hydrates, then connects to the stream to receive data chunks:
import { createDOMAdapter } from '@rimitive/view/adapters/dom';import { createDOMHydrationAdapter, createHydrationAdapter, connectStream,} from '@rimitive/ssr/client';import { createService } from './service.js';import { App } from './App.js';
const STREAM_KEY = '__APP_STREAM__';
// Create hydration adapter (same as basic SSR)const hydrationAdapter = createHydrationAdapter( createDOMHydrationAdapter(document.querySelector('.app')!), createDOMAdapter());
// Create service (no hydrationData - data comes via stream instead)const service = createService(hydrationAdapter);
// Hydrate the appApp(service).create(service);
// Switch to normal DOM modehydrationAdapter.switchToFallback();
// Connect to the stream to receive data chunksconnectStream(service, STREAM_KEY);Using load() with Streaming
Section titled “Using load() with Streaming”Components use load() exactly the same as basic async SSR.
Error Handling
Section titled “Error Handling”Errors in load() boundaries work the same as basic async SSR. With streaming:
- If a fetch fails,
onResolveisn’t called for that boundary - The
donepromise still resolves (one error doesn’t break other boundaries) - The error state renders on the client via
state.error()
For server-level errors, wrap the response:
const server = createServer(async (req, res) => { try { // ... streaming setup await done; res.write('</body></html>'); res.end(); } catch (err) { console.error('Streaming error:', err); if (!res.headersSent) { res.writeHead(500); res.end('Server Error'); } else { res.end(); // Headers sent, close gracefully } }});Complete Example
Section titled “Complete Example”A working streaming example with multiple load() boundaries that resolve at different times:
// App.ts - Multiple load() boundaries with different speedsexport const App = (svc: Service) => () => { const { loader, match, el } = svc;
return el('div')( // Fast - 100ms loader.load('user', fetchUser, (state) => match(state.status, (s) => { if (s === 'pending') return el('div')('Loading user...'); if (s === 'error') return el('div')('Error'); return el('div')(`Welcome, ${state.data()!.name}!`); }) ),
// Slow - 3s loader.load('news', fetchNews, (state) => match(state.status, (s) => { if (s === 'pending') return el('div')('Loading news...'); if (s === 'error') return el('div')('Error'); return el('ul')(...state.data()!.headlines.map(h => el('li')(h))); }) ) );};With streaming:
- User sees “Loading user…” and “Loading news…” immediately
- After 100ms, user greeting appears (first chunk streamed)
- After 3s, news list appears (second chunk streamed)
When to Use Streaming
Section titled “When to Use Streaming”| Use Streaming When | Use Basic Async SSR When |
|---|---|
| Some data sources are slow (> 500ms) | All data loads quickly |
| Users should see content ASAP | SEO crawlers need complete HTML |
| Page has independent data regions | Simplicity matters more than speed |