Menu

Tutorial

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 use <kovo-defer> to render a fallback in the shell and stream the real fragment later, in the same response. Step state: site/tutorial/steps/06-streaming/.

Defer an expensive fragment#

Deferred content reuses a mechanism you already have. The chunks that arrive after the shell are the same <kovo-query> and <kovo-fragment> elements the mutation wire used in chapters 4 and 5 — the fragment protocol, reused within first render. Nothing new ships in the loader, and nothing new needs auditing on the wire.

ts
function renderShopPageDeferredStream(db = createShopDb(), request?: ShopRequest) {
  const cart = loadCart(db);
  const shell = `<!doctype html><html><head><title>Kovo Shop</title></head><body><main><h1>Kovo Shop</h1><kovo-fragment target="cart-badge">${CartBadge.definition.render({ cart })}</kovo-fragment><kovo-defer target="product-list" state="pending">Loading products...</kovo-defer>`;
  const products = loadProducts(db);

  return renderDeferredStream({
    chunks: [
      {
        fragments: [
          {
            html: expectSyncHtml(ProductList.definition.render({ products }, { request })),
            target: 'product-list',
          },
        ],
        queries: [{ name: 'products', value: products }],
      },
    ],
    closeHtml: '</main></body></html>',
    shell,
  });
}

The shell carries the cart badge (cheap, rendered inline) and a <kovo-defer> placeholder with declared fallback content. The stream then appends the products query value and the product-list fragment; 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', () => {
  const response = 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 ordering guarantee that keeps the client coherent: deferred query JSON arrives before or with its consumers, so a fragment can never render against data the document does not hold yet:

ts
it('guarantees deferred query JSON arrives before or with its consumers', () => {
  const response = renderShopPageDeferredStream(createShopDb());

  const queryIndex = response.body.indexOf('<kovo-query name="products">');
  const fragmentIndex = response.body.indexOf('<kovo-fragment target="product-list">');
  expect(queryIndex).toBeGreaterThan(-1);
  expect(queryIndex).toBeLessThan(fragmentIndex);
});

When to defer#

<kovo-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 (SPEC §4.5). 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 <kovo-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 (SPEC §13.3).

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 <kovo-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 one ordering rule that protects this is the guarantee you assert above: a deferred query's JSON arrives before or with the fragment that binds to it, so the document never holds a binding whose data hasn't landed. 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 value (SPEC §8).

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

<kovo-defer> and streaming within first render: SPEC §8. Reused fragment protocol and morph over the placeholder: SPEC §9.1. Deferred query JSON ordered before or with its consumers: SPEC §8. Projected children ship in initial HTML; <kovo-defer> is the only lazy-content mechanism: SPEC §4.5. Priority and HTTP/1.1 considerations: SPEC §13.3.