Streaming & defer
Suppose the product list gets expensive — a slow join, a recommendations service. Blocking the
whole document on it would trade away the MPA's instant first paint. In this chapter you author
<Defer> in TSX so the shell can paint now and the product list can stream later in the same
response. Step state: site/tutorial/steps/06-streaming/.
Defer an expensive fragment#
Import Defer from @kovojs/server. Inside the TSX returned by route().page, give it a stable
target, render an honest fallback, and put the slow region behind render:
import { Defer } from '@kovojs/server';
<Defer
target="product-list"
priority="after-paint"
fallback={<section aria-busy="true">Loading products...</section>}
render={() => <ProductList />}
/>;Keep the boundary inside the request-rendered page. JSX function components execute where they are
created; constructing <Defer> at module scope would run outside the request's deferred-region
collector. priority="after-paint" is the explicit streaming posture; the default critical
priority renders inline.
Deferred content reuses a mechanism you already have. The chunks that arrive after the shell use
the same <kovo-fragment> element the mutation wire used in chapters 4 and 5. When a chunk also
carries shared query JSON, the serializer places it before its consumers. The emitted placeholder
is <kovo-defer>, but app code authors <Defer>.
async function renderShopPageDeferredStream(db = createShopDb()) {
const request = shopRequest(db);
const response = await renderRoutePageResponse(homeRoute, {}, request, renderRouteHtml, {
attestationAuthority: tutorialLiveTargetAuthority.authority,
...(tutorialWireCsrf === undefined ? {} : { csrf: tutorialWireCsrf }),
});
if (typeof response.body !== 'string') throw new Error('expected a string page body');
const pendingChunks =
'deferredChunks' in response && Array.isArray(response.deferredChunks)
? response.deferredChunks
: [];
const chunks: DeferredStreamChunk[] = await Promise.all(pendingChunks);
return renderDeferredStream({
chunks,
shell: response.body,
});
}The shell carries the cart badge, which is cheap and rendered inline. Kovo emits a <kovo-defer>
wire placeholder with your fallback content. The stream then appends the complete product-list
fragment; its compiler-derived kovo-deps stamp still names the products query, and the loader
morphs the fragment over the placeholder exactly as it would morph a mutation response.
Assert the stream as a string#
A streamed response is still text in order, so the guarantees are string assertions. First: the shell precedes the fragment. Paint now, fill in later:
it('streams the shell first, the product list later in the same response', async () => {
const response = await renderShopPageDeferredStream(createShopDb());
expect(response).toMatchObject({
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
status: 200,
});
// The shell renders a declared fallback…
expect(response.body).toContain('<kovo-defer target="product-list" state="pending"');
// …and the real fragment follows in the same body, after the shell.
const deferIndex = response.body.indexOf('<kovo-defer target="product-list"');
const fragmentIndex = response.body.indexOf('<kovo-fragment target="product-list"');
expect(deferIndex).toBeGreaterThan(-1);
expect(fragmentIndex).toBeGreaterThan(deferIndex);
});Second, the identity guarantee that keeps later refreshes coherent: the deferred consumer carries the same compiler-derived query dependency it would have carried in the initial shell:
it('keeps the deferred consumer bound to its compiler-derived query identity', async () => {
const response = await renderShopPageDeferredStream(createShopDb());
const fragmentIndex = response.body.indexOf('<kovo-fragment target="product-list"');
const dependencyIndex = response.body.indexOf(
`kovo-deps="${encodeTutorialQueryDependency(productsQuery.key)}"`,
);
expect(fragmentIndex).toBeGreaterThan(-1);
expect(dependencyIndex).toBeGreaterThan(fragmentIndex);
});When to defer#
<Defer> is the relief valve for expensive subtrees, and it's the only lazy-content
mechanism — projected children otherwise ship in the initial HTML, which is the MPA model, not an
oversight. Reach for it when a fragment's render cost would delay first paint; skip it
when the data is cheap, because a placeholder that flashes for 10ms is worse than content.
Multiple defers in one response#
A page can hold more than one <Defer>, and each is independent: a slow recommendations rail
and a slow reviews block can both stream while the shell — and everything cheap in it — paints
immediately. Split them when their costs differ, so a 50ms fragment isn't held behind a 2s one;
each chunk arrives and morphs in as its own work finishes. Don't over-split, though. Every defer
adds a placeholder that flashes, so group content that resolves together behind one boundary rather
than scattering a dozen tiny defers across the page. Priority — which late region the server should
flush first — is declared on the route/component surface that owns it, not inferred.
The HTTP/1.1 head-of-line caveat#
The whole streamed response is one ordered byte stream, so the transport matters. Over HTTP/2 (or
HTTP/3), the connection multiplexes — other requests on the page, like a client island's first
import or a navigation prefetch, interleave with the in-flight stream and don't wait behind it.
Over HTTP/1.1 there is no multiplexing on a single connection: a long-running deferred response can
hold the line, and a browser limited to a handful of parallel HTTP/1.1 connections per origin can
stall other requests behind your slow fragment. This is a property of the transport, not of
<Defer> — but it changes the calculus. On HTTP/1.1, a defer that takes seconds can cost you
more in blocked sibling requests than it saves in first paint, so prefer fewer, coarser defers and
make sure your hosting terminates HTTP/2. Finer priority semantics and query-JSON placement under
HTTP/1.1 fallbacks are still open design areas; the before-or-with ordering guarantee below is the
contract you can depend on regardless of transport. The streaming guide
covers priority and HTTP/1.1 considerations in full.
How defer interacts with invalidation#
A deferred query is still a real query — it carries the same kovo-deps stamps and the same read
set as one rendered inline. So once the fragment lands, it is a full participant in the
invalidation loop from chapter 5: if a later mutation touches a domain the deferred query reads,
that query re-runs and the deferred island updates exactly like any other dependent island. Nothing
special is needed because the defer arrived late — the loader has already wired its bindings by the
time the mutation's response comes back. The identity rule you assert above protects that loop: the
arriving fragment retains its compiler-derived query dependency, so later invalidation selects the
same consumer. A mutation that fires while a defer is still streaming sees a coherent document
either way: it refreshes whatever query values are present, and the deferred chunk, when it arrives,
carries the freshest server-rendered value.
The app now paints fast, updates instantly, and degrades gracefully. What remains is the framework's biggest claim: proving all of this behavior, mechanically, without a browser.
Spec & diagnostics
<Defer> and streaming within first render: SPEC §8. Reused fragment protocol and morph over the
framework-emitted <kovo-defer> placeholder: SPEC §9.1. Deferred consumers retain their
compiler-derived query identity: SPEC §8. Projected children ship in initial HTML; Defer is the only lazy-content
mechanism: SPEC §4.5. Priority and HTTP/1.1 considerations: SPEC §13.3. App-authored defer(...)
as a JSX child is KV244.