Skip to content

Streaming & Suspense

WebJs supports streaming SSR with Suspense boundaries. The server flushes the page shell (header, layout, fast content) immediately, then streams deferred content as it resolves. The browser paints above-the-fold content in milliseconds while slow data trickles in.

How It Works

  1. The page returns a template containing Suspense({ fallback, children }) markers.
  2. renderToString encounters a Suspense boundary → emits the fallback HTML wrapped in a <webjs-boundary id="sN"> placeholder.
  3. The SSR pipeline flushes the document head + body (with fallbacks) immediately. This is the first byte the browser sees.
  4. The response stream stays open. As each Suspense promise resolves, the server emits a <template data-webjs-resolve="sN">...real content...</template> followed by a tiny inline script that swaps the fallback for the real content.
  5. The browser progressively replaces fallbacks as data arrives. No framework runtime needed, just a one-line replaceWith call per boundary.

Usage

import { html, Suspense } from '@webjsdev/core';

async function SlowStats() {
  const data = await fetchExpensiveAnalytics();
  return html`<div>${data.summary}</div>`;
}

export default function Dashboard() {
  return html`
    <h1>Dashboard</h1>
    <p>Welcome back!</p>

    ${Suspense({
      fallback: html`<p>Loading stats…</p>`,
      children: SlowStats(),
    })}
  `;
}

The user sees "Loading stats…" immediately. When SlowStats() resolves (maybe 500ms later), the real content streams in and replaces the fallback, without a full page reload or client-side JS re-render.

TTFB Impact

Without Suspense, the server waits for ALL data before sending the first byte. With Suspense, TTFB equals the time to render everything OUTSIDE the boundaries, typically milliseconds:

Without Suspense:  TTFB = slowest await = 500ms
With Suspense:     TTFB = shell render = ~40ms
                   Total = still 500ms (stream completes)
                   But the user sees content 460ms earlier.

Nested Suspense

Boundaries can nest. A resolved boundary can itself contain Suspense, and the server keeps streaming until all promises drain:

${Suspense({
  fallback: html`<p>Loading section…</p>`,
  children: (async () => {
    const section = await loadSection();
    return html`
      <h2>${section.title}</h2>
      ${Suspense({
        fallback: html`<p>Loading details…</p>`,
        children: loadDetails(section.id),
      })}
    `;
  })(),
})}

Without a Suspense Context

If renderToString is called without a suspenseCtx (e.g., in a static pre-render), Suspense boundaries render the fallback only, and the promise is dropped. This is the safe default for contexts where streaming isn't available.

Client-Side Resolver

The tiny client-side script (auto-injected when any Suspense boundary is present) is a single function:

window.__webjsResolve = function(id) {
  const tpl = document.querySelector('template[data-webjs-resolve="' + id + '"]');
  const boundary = document.getElementById(id);
  if (tpl && boundary) {
    boundary.replaceWith(tpl.content.cloneNode(true));
    tpl.remove();
  }
};

No framework runtime. No hydration. Just a DOM swap.

Component-level Suspense: <webjs-suspense>

The Suspense({ fallback, children }) primitive above is page/region-level (you pass a promise as children). With async render, a COMPONENT can be the suspending unit instead. A component that does async render() { const u = await getUser(this.uid); ... } BLOCKS the first byte by default (the data is in the first paint). To STREAM a slow component, wrap it in the <webjs-suspense> element:

html`
  <webjs-suspense .fallback=${html`<p>Loading section…</p>`}>
    <user-profile uid="42"></user-profile>
    <user-activity uid="42"></user-activity>
  </webjs-suspense>
`;

The decoupled model: SSR blocks by default (real data in the first paint, no fallback), and <webjs-suspense> is the EXPLICIT opt-in that flushes the fallback on the first byte and streams the data in. It reuses the same boundary engine as page-level Suspense, so:

  • Grouping + override. One boundary wraps several components under ONE fallback; the boundary .fallback wins over a contained component's renderFallback().
  • Concurrent. Multiple boundaries fetch their data in parallel (no server waterfall), streaming fast-before-slow.
  • Error-isolated. A throwing component inside a boundary renders its own error state while siblings stream.
  • Progressive on soft navigation. A client-router navigation to a streamed page applies the shell (with fallbacks) immediately, advances the URL, then streams each boundary in, matching the initial-load experience.

The .fallback is read at SSR as the inline placeholder (never through the data-webjs-prop-* path, since a TemplateResult is not serializer-safe) and must be an unquoted property hole. renderFallback() on a component is a DIFFERENT concern (the client re-fetch loading state, never the first paint); see Loading States.

When to Use Suspense

  • Slow database queries: wrap the section that depends on slow data.
  • External API calls: third-party services with unpredictable latency.
  • Personalised content: user-specific data that can't be cached, below fast-to-render public content.

Don't wrap everything in Suspense, only the parts whose data is genuinely slow. Fast queries should remain in the synchronous render path for simpler code.