Streaming SSR
Progressive HTML rendering with ReadableStream for improved TTFB
Streaming SSR
Constela supports streaming server-side rendering using the Web Streams API. This enables progressive HTML delivery for improved Time to First Byte (TTFB) and better user experience.
Configuration
Enable streaming in constela.config.json:
{
"streaming": {
"enabled": true,
"flushStrategy": "batched"
}
}| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable streaming SSR |
flushStrategy | string | "batched" | Chunk flushing strategy |
Flush Strategies
immediate
Flush each chunk as soon as it's ready. Best for real-time streaming and long pages where users benefit from seeing content immediately.
{
"streaming": {
"enabled": true,
"flushStrategy": "immediate"
}
}batched (Default)
Batch chunks until buffer exceeds 1KB threshold. Balances streaming with network efficiency.
{
"streaming": {
"enabled": true,
"flushStrategy": "batched"
}
}manual
Only flush at the end. Use for small pages where streaming provides no benefit.
{
"streaming": {
"enabled": true,
"flushStrategy": "manual"
}
}Suspense Boundaries
Use suspense nodes for async content. The server renders fallback content immediately, then streams the actual content when ready.
Basic Suspense
{
"kind": "suspense",
"id": "user-data",
"fallback": {
"kind": "element",
"tag": "div",
"props": { "className": { "expr": "lit", "value": "skeleton" } }
},
"content": {
"kind": "component",
"name": "UserProfile"
}
}Suspense Properties
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier for the boundary |
fallback | ViewNode | Yes | Loading state content |
content | ViewNode | Yes | Async content to load |
timeout | number | No | Fallback timeout in ms |
Nested Suspense
Suspense boundaries can be nested for granular loading states:
{
"kind": "suspense",
"id": "dashboard",
"fallback": { "kind": "component", "name": "DashboardSkeleton" },
"content": {
"kind": "element",
"tag": "div",
"children": [
{
"kind": "suspense",
"id": "user-profile",
"fallback": { "kind": "component", "name": "ProfileSkeleton" },
"content": { "kind": "component", "name": "UserProfile" }
},
{
"kind": "suspense",
"id": "activity-feed",
"fallback": { "kind": "component", "name": "FeedSkeleton" },
"content": { "kind": "component", "name": "ActivityFeed" }
}
]
}
}Server Output
The server renders suspense boundaries with markers:
<div data-suspense-id="user-data">
<div class="skeleton"></div>
</div>When content is ready, it streams as:
<template data-suspense-replace="user-data">
<div class="user-profile">...</div>
</template>
<script>__CONSTELA_RESOLVE__("user-data")</script>Server Render Options
Configure server-side rendering behavior in your program:
{
"version": "1.0",
"ssr": {
"route": {
"params": { "id": "123" },
"query": { "tab": "overview" },
"path": "/users/123"
},
"stateOverrides": {
"user": { "name": "John", "email": "john@example.com" }
},
"cookies": {
"theme": "dark",
"locale": "ja-JP"
}
}
}Route Context
| Property | Type | Description |
|---|---|---|
params | Record<string, string> | Route parameters |
query | Record<string, string> | Query string parameters |
path | string | Full request path |
State Overrides
Pre-populate state values for SSR:
{
"ssr": {
"stateOverrides": {
"user": { "name": "John", "authenticated": true },
"theme": "dark"
}
}
}Cookie Support
Pass cookies for SSR-safe state initialization:
{
"ssr": {
"cookies": {
"sessionId": "abc123",
"preferences": "{\"theme\":\"dark\"}"
}
}
}Performance Tips
- Use
batchedby default - Balances streaming with network efficiency - Use
immediatefor long pages - Users see content faster - Use
manualfor small pages - Reduces overhead - Add suspense boundaries - Shows loading states for async content
- Enable gzip/brotli - Compress streamed chunks
Advanced: Server API
This section covers the TypeScript API for custom server implementations. Most applications don't need this - the built-in server handles streaming automatically based on your configuration.
renderToStream
Render a compiled program to a ReadableStream:
import { renderToStream } from '@constela/server';
const stream = renderToStream(compiledProgram, {
flushStrategy: 'batched',
});createHtmlTransformStream
Wrap content with HTML document structure:
import { createHtmlTransformStream } from '@constela/server';
const htmlStream = contentStream.pipeThrough(
createHtmlTransformStream({
title: 'My Page',
lang: 'en',
meta: {
description: 'Page description',
'og:title': 'My Page',
},
stylesheets: ['/styles.css', '/theme.css'],
scripts: ['/client.js'],
})
);| Option | Type | Description |
|---|---|---|
title | string | Page title (required) |
lang | string | HTML lang attribute (default: "en") |
meta | Record<string, string> | Meta tags |
stylesheets | string[] | CSS file paths |
scripts | string[] | Script file paths |
StreamRenderOptions
interface StreamRenderOptions {
route?: {
params?: Record<string, string>;
query?: Record<string, string>;
path?: string;
};
imports?: Record<string, unknown>;
styles?: Record<string, StylePreset>;
stateOverrides?: Record<string, unknown>;
cookies?: Record<string, string>;
signal?: AbortSignal;
}Abort Signal
Cancel streaming when the client disconnects:
const controller = new AbortController();
const stream = renderToStream(program, { flushStrategy: 'batched' }, {
signal: controller.signal,
});
// Cancel on client disconnect
request.signal.addEventListener('abort', () => {
controller.abort();
});Complete Server Example
import { renderToStream, createHtmlTransformStream } from '@constela/server';
async function handleRequest(request: Request) {
const stream = renderToStream(program, { flushStrategy: 'batched' }, {
route: {
params: { id: '123' },
path: '/users/123',
},
cookies: Object.fromEntries(
request.headers.get('cookie')?.split(';').map(c => c.trim().split('=')) ?? []
),
signal: request.signal,
});
const htmlStream = stream.pipeThrough(
createHtmlTransformStream({
title: 'User Profile',
stylesheets: ['/styles.css'],
scripts: ['/client.js'],
})
);
return new Response(htmlStream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}Edge Runtime Examples
Cloudflare Workers
import { renderToStream, createHtmlTransformStream } from '@constela/server';
export default {
async fetch(request: Request) {
const stream = renderToStream(program, { flushStrategy: 'batched' })
.pipeThrough(createHtmlTransformStream({ title: 'My App' }));
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
},
};Vercel Edge
import { renderToStream, createHtmlTransformStream } from '@constela/server';
export const config = { runtime: 'edge' };
export default async function handler(request: Request) {
const stream = renderToStream(program, { flushStrategy: 'batched' })
.pipeThrough(createHtmlTransformStream({ title: 'My App' }));
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}Deno Deploy
import { renderToStream, createHtmlTransformStream } from '@constela/server';
Deno.serve(async (request) => {
const stream = renderToStream(program, { flushStrategy: 'batched' })
.pipeThrough(createHtmlTransformStream({ title: 'My App' }));
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
});