Menu

Tutorial

View as Markdown

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:

tsx
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>.

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

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

ts
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.