GitHub

Streaming SSR

Streaming SSR eliminates the tradeoff between fast paint and real content. The shell — chrome, navigation, above-the-fold layout — renders and streams immediately. Slower data-dependent segments arrive over the same connection without blocking the initial paint.

How it works

Without streaming, the server waits for all data to resolve before sending any HTML — slow queries block the entire response. Pulse splits the view into a shell (sent immediately) and deferred segments (sent as placeholders, then replaced when data resolves).

Deferred segments arrive as chunks of HTML over the same connection — no extra requests, no client-side JavaScript required to swap content in.

Enabling streaming

To use streaming, the view is an object of named segment functions rather than a single function. The spec declares which segments are in the shell and which are deferred:

export default {
  route: '/dashboard',
  state: {},
  server: {
    data: async (ctx) => ({
      user:   await auth.getUser(ctx.cookies.sessionId),  // fast
      feed:   await db.feed.latest(),                      // slow
      stats:  await analytics.summary(ctx.params.id),      // slow
    }),
  },
  stream: {
    shell:    ['header', 'nav'],     // rendered immediately
    deferred: ['feed', 'stats'],     // streamed when server data resolves
  },
  view: {
    header: (state, server) => `
      <header class="site-header">
        <a href="/">Dashboard</a>
        <span>Hello, ${server.user.name}</span>
      </header>
    `,
    nav: () => `
      <nav>
        <a href="/dashboard">Home</a>
        <a href="/settings">Settings</a>
      </nav>
    `,
    feed: (state, server) => `
      <section class="feed">
        ${server.feed.map(item => `<article>${item.title}</article>`).join('')}
      </section>
    `,
    stats: (state, server) => `
      <div class="stats">
        <p>Page views: ${server.stats.views}</p>
        <p>Conversions: ${server.stats.conversions}</p>
      </div>
    `,
  },
}

Deferred placeholders

While deferred segments are loading, Pulse renders a <div id="pulse-slot-[name]"> placeholder in their place. When the segment resolves, the rendered HTML is appended to the stream and a small inline script swaps the placeholder content.

The swap is done with a tiny inline script — not a separate JS bundle. Deferred streaming works even on pages with no hydration (hydrate omitted).

Server data and streaming

All server.data() calls resolve in a single async call before rendering begins. Streaming is about splitting the view — not about parallelising data fetching. For parallel data fetching, use Promise.all inside server.data():

server: {
  data: async (ctx) => {
    // Fetch in parallel — both requests run concurrently
    const [feed, stats] = await Promise.all([
      db.feed.latest(),
      analytics.summary(),
    ])
    return { feed, stats }
  },
}

When to use streaming

ScenarioUse streaming?
Page with fast server data (< 20ms)No — standard SSR is simpler and fast enough
Page with slow database queriesYes — stream the shell while data loads
Pages with above-the-fold and below-the-fold contentYes — shell renders above the fold immediately
API or raw response endpointsNo — use raw responses

Server-level streaming option

Streaming is enabled by default in createServer. Disable it globally with stream: false:

createServer(specs, {
  stream: false,  // disable streaming for all specs
})

Even with global streaming enabled, only specs that declare a stream field will use chunked responses. All other specs use regular buffered SSR.

Streaming is most beneficial on pages with large datasets or slow queries. For most pages, the speed of Pulse's synchronous rendering means streaming adds unnecessary complexity.