Skip to content

Streaming SSR

This guide builds on SSR with Data Loading. Make sure you understand load() and basic SSR setup first.


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.


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 receiver
stream.bootstrapCode();
// → "window.__APP_STREAM__=(function(){...})();"
// Chunk code is sent as data resolves - pushes to the receiver
stream.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.


Use renderToStream instead of renderToStringAsync:

server.ts
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);

Add onResolve to your service options and pass it through to createLoaderModule:

// service.ts - add to your existing ServiceOptions
export type ServiceOptions = {
hydrationData?: Record<string, unknown>;
onResolve?: (id: string, data: unknown) => void; // 👈 Add this
};
// Pass it through to the loader
createLoaderModule({
initialData: options?.hydrationData,
onResolve: options?.onResolve, // 👈 Add this
})

See the SSR with Data Loading service setup for the full example.


The client hydrates, then connects to the stream to receive data chunks:

client.ts
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 app
App(service).create(service);
// Switch to normal DOM mode
hydrationAdapter.switchToFallback();
// Connect to the stream to receive data chunks
connectStream(service, STREAM_KEY);

Components use load() exactly the same as basic async SSR.


Errors in load() boundaries work the same as basic async SSR. With streaming:

  1. If a fetch fails, onResolve isn’t called for that boundary
  2. The done promise still resolves (one error doesn’t break other boundaries)
  3. 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
}
}
});

A working streaming example with multiple load() boundaries that resolve at different times:

// App.ts - Multiple load() boundaries with different speeds
export 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:

  1. User sees “Loading user…” and “Loading news…” immediately
  2. After 100ms, user greeting appears (first chunk streamed)
  3. After 3s, news list appears (second chunk streamed)

Use Streaming WhenUse Basic Async SSR When
Some data sources are slow (> 500ms)All data loads quickly
Users should see content ASAPSEO crawlers need complete HTML
Page has independent data regionsSimplicity matters more than speed