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:

json
{
  "streaming": {
    "enabled": true,
    "flushStrategy": "batched"
  }
}
OptionTypeDefaultDescription
enabledbooleanfalseEnable streaming SSR
flushStrategystring"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.

json
{
  "streaming": {
    "enabled": true,
    "flushStrategy": "immediate"
  }
}

batched (Default)

Batch chunks until buffer exceeds 1KB threshold. Balances streaming with network efficiency.

json
{
  "streaming": {
    "enabled": true,
    "flushStrategy": "batched"
  }
}

manual

Only flush at the end. Use for small pages where streaming provides no benefit.

json
{
  "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

json
{
  "kind": "suspense",
  "id": "user-data",
  "fallback": {
    "kind": "element",
    "tag": "div",
    "props": { "className": { "expr": "lit", "value": "skeleton" } }
  },
  "content": {
    "kind": "component",
    "name": "UserProfile"
  }
}

Suspense Properties

PropertyTypeRequiredDescription
idstringYesUnique identifier for the boundary
fallbackViewNodeYesLoading state content
contentViewNodeYesAsync content to load
timeoutnumberNoFallback timeout in ms

Nested Suspense

Suspense boundaries can be nested for granular loading states:

json
{
  "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:

html
<div data-suspense-id="user-data">
  <div class="skeleton"></div>
</div>

When content is ready, it streams as:

html
<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:

json
{
  "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

PropertyTypeDescription
paramsRecord<string, string>Route parameters
queryRecord<string, string>Query string parameters
pathstringFull request path

State Overrides

Pre-populate state values for SSR:

json
{
  "ssr": {
    "stateOverrides": {
      "user": { "name": "John", "authenticated": true },
      "theme": "dark"
    }
  }
}

Cookie Support

Pass cookies for SSR-safe state initialization:

json
{
  "ssr": {
    "cookies": {
      "sessionId": "abc123",
      "preferences": "{\"theme\":\"dark\"}"
    }
  }
}

Performance Tips

  1. Use batched by default - Balances streaming with network efficiency
  2. Use immediate for long pages - Users see content faster
  3. Use manual for small pages - Reduces overhead
  4. Add suspense boundaries - Shows loading states for async content
  5. 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:

typescript
import { renderToStream } from '@constela/server';

const stream = renderToStream(compiledProgram, {
  flushStrategy: 'batched',
});

createHtmlTransformStream

Wrap content with HTML document structure:

typescript
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'],
  })
);
OptionTypeDescription
titlestringPage title (required)
langstringHTML lang attribute (default: "en")
metaRecord<string, string>Meta tags
stylesheetsstring[]CSS file paths
scriptsstring[]Script file paths

StreamRenderOptions

typescript
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:

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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' },
  });
});