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
- The page returns a template containing
Suspense({ fallback, children })markers. renderToStringencounters a Suspense boundary → emits the fallback HTML wrapped in a<webjs-boundary id="sN">placeholder.- The SSR pipeline flushes the document head + body (with fallbacks) immediately. This is the first byte the browser sees.
- 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. - The browser progressively replaces fallbacks as data arrives. No framework runtime needed, just a one-line
replaceWithcall 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
.fallbackwins over a contained component'srenderFallback(). - 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.