Streaming the App Shell from a Service Worker

After reading this you will be able to build a service worker response that stitches a cached shell around a live body, so the header and navigation reach the HTML parser immediately while the content is still arriving from the network.

← Back to Navigation Preload & Streaming

Prerequisites

Core Concept

The HTML parser is streaming. It starts building the DOM as soon as the first bytes arrive rather than waiting for a complete document, which is why server-side early flush works: send the <head> and the header markup immediately, and the browser can fetch the stylesheet and paint the chrome while the server is still assembling the rest.

A service worker can do the same thing without touching the server’s rendering path. Construct a Response from a ReadableStream, enqueue the cached header fragment synchronously, pipe the network body through chunk by chunk as it arrives, then enqueue the cached footer. The parser starts on the first chunk — which came from the cache and therefore arrived in the same frame — so First Contentful Paint no longer waits for the network at all.

The requirement this creates is that the page must be splittable into three pieces that are valid when concatenated. Concretely: head.html ends mid-document with unclosed tags (typically inside the content container), the body fragment is a partial that fits inside it, and tail.html closes everything. That is unusual to look at and completely legal — the parser has no objection to receiving a document in pieces, because that is how every document arrives.

Two constraints follow directly from streaming. The composed response must not carry a Content-Length, because you do not know it; copying headers wholesale from the network response is the most common cause of a stream that buffers instead of streaming. And once bytes are flushed you cannot change your mind — there is no way to switch to an error page after the header has been sent, so failures must be handled inline.

Composing one response from three sources A single output stream receives the cached head fragment at zero milliseconds, the network body chunks as they arrive, and the cached tail fragment at the end. First contentful paint occurs shortly after the head fragment rather than after the whole response. One Response, three sources, in order head.html (cache) chunk 1 chunk 2 chunk 3 tail.html (cache) 0ms body complete First Contentful Paint — header and nav are visible here head.html — ends mid-document <!doctype html><html><head>…</head> <body><header>…</header><main id="content"> unclosed tags are fine — the parser is streaming tail.html — closes what head opened </main><footer>…</footer> <script src="/app.js" defer></script></body></html> version both fragments with the build hash
The head fragment comes from the cache and reaches the parser immediately; the network only has to deliver the middle.

Implementation

Precache the fragments on install, versioned so a deploy cannot pair an old header with a new body.

// TypeScript 5.x — sw.ts
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

const BUILD = "a91f3c";                 // injected by the build
const SHELL_CACHE = `shell-${BUILD}`;

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(SHELL_CACHE).then((cache) =>
      cache.addAll([`/shell/head.${BUILD}.html`, `/shell/tail.${BUILD}.html`]),
    ),
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // Delete every shell cache that is not this build's.
      const names = await caches.keys();
      await Promise.all(
        names.filter((n) => n.startsWith("shell-") && n !== SHELL_CACHE).map((n) => caches.delete(n)),
      );
      if ("navigationPreload" in self.registration) {
        await self.registration.navigationPreload.enable();
        await self.registration.navigationPreload.setHeaderValue("fragment");
      }
      await self.clients.claim();
    })(),
  );
});

The composition itself. Note the try/catch around the body read: once the head has been flushed there is no way back to an error page, so a failure must be rendered inline.

// TypeScript 5.x — stitch cache and network into one streamed document

const encoder = new TextEncoder();

async function streamedDocument(event: FetchEvent): Promise<Response> {
  const cache = await caches.open(SHELL_CACHE);
  const [head, tail] = await Promise.all([
    cache.match(`/shell/head.${BUILD}.html`),
    cache.match(`/shell/tail.${BUILD}.html`),
  ]);
  // No shell cached yet (first visit): fall back to a plain network response.
  if (!head || !tail) return (await event.preloadResponse as Response) ?? fetch(event.request);

  const [headText, tailText] = await Promise.all([head.text(), tail.text()]);

  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      controller.enqueue(encoder.encode(headText)); // paints immediately

      try {
        const body = (await event.preloadResponse as Response | undefined)
          ?? (await fetch(event.request));
        const reader = body.body?.getReader();
        if (reader) {
          for (;;) {
            const { done, value } = await reader.read();
            if (done) break;
            controller.enqueue(value); // forward as it arrives
          }
        }
      } catch {
        // Bytes are already flushed — degrade inline rather than rejecting.
        controller.enqueue(
          encoder.encode('<p class="offline-notice">This page is unavailable offline.</p>'),
        );
      }

      controller.enqueue(encoder.encode(tailText));
      controller.close();
    },
  });

  return new Response(stream, {
    // Build the headers deliberately. Copying the network response's headers
    // brings Content-Length with it, which forces the browser to buffer.
    headers: {
      "Content-Type": "text/html; charset=utf-8",
      "Cache-Control": "no-store",
    },
  });
}

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.mode !== "navigate" || request.destination !== "document") return;
  event.respondWith(streamedDocument(event));
});

The origin’s half is a single branch on the header the worker set:

// TypeScript 5.x — return only the fragment when the worker asked for one

export function render(request: Request): Response {
  const wantsFragment =
    request.headers.get("Service-Worker-Navigation-Preload") === "fragment";

  const body = renderMainContent(request); // the part between head and tail
  return wantsFragment
    ? new Response(body, { headers: { "Content-Type": "text/html; charset=utf-8" } })
    : new Response(renderFullDocument(body), {
        headers: { "Content-Type": "text/html; charset=utf-8" },
      });
}
Why a Content-Length header defeats streaming Two responses. The correct one declares only a content type, so the browser renders each chunk as it arrives. The broken one copies Content-Length from the network response, so the browser buffers the whole body before rendering and first paint moves to the end. Correct — headers built deliberately Content-Type: text/html; charset=utf-8 Cache-Control: no-store no Content-Length → chunks render as they arrive paint paint paint Broken — headers copied from the network response ...Object.fromEntries(networkResponse.headers) Content-Length: 48213 ← wrong, and fatal browser waits for exactly that many bytes buffering… one paint Symptom: the technique appears to work but First Contentful Paint still tracks total response time.
Construct the composed response's headers explicitly — copying them is what turns a stream back into a buffered response.

Verification

The signal to look for is that First Contentful Paint decouples from total response time.

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("the shell paints before the body finishes downloading", async ({ page }) => {
  await page.goto("/");
  await page.evaluate(() => navigator.serviceWorker.ready);

  // Slow the body so the difference is unambiguous.
  await page.route("**/reports", async (route) => {
    await new Promise((r) => setTimeout(r, 1200));
    await route.continue();
  });

  await page.getByRole("link", { name: "Reports" }).click();

  // The header comes from cache, so it must be visible well before the content.
  await expect(page.getByRole("banner")).toBeVisible({ timeout: 400 });
  await expect(page.locator("[data-route='reports']")).toBeVisible({ timeout: 3000 });
});

In DevTools, open the Network panel and look at the document row: a streamed response shows a long “Content Download” phase, and the Performance panel should place First Contentful Paint near its start rather than its end. If FCP moves in lockstep with total response time, something is buffering — check for a Content-Length on the composed response first.

Gotchas

  • Never copy the network response’s headers. Content-Length will be wrong and will force buffering; build the composed headers explicitly.
  • The fragments must concatenate into valid HTML. Test the joined output once in CI rather than discovering a mismatched tag in production.
  • Version the fragments with the build hash. A deploy that pairs an old header with a new body produces subtly broken markup that no test written against either half will catch.
  • Keep fragments small. They are encoded synchronously before the first body chunk; a 60 KB header delays everything behind it.
  • Errors after the first flush are unrecoverable. Handle them inline, as the catch above does, rather than letting the stream reject and leaving a half-rendered page.
  • Charset must agree across the three pieces. A UTF-8 head with a differently encoded body produces mojibake only in the middle, which reads as a data bug.
  • First visits have no cached shell. The fallback path must be a complete strategy, not a stub.
Four streaming symptoms and their causes Four observable symptoms of a broken streamed response — first paint tracking total time, a page that never finishes loading, duplicated chrome, and garbled body text — each paired with its single cause. Diagnosing a stream that is not streaming what you observe cause FCP tracks total response time no early paint at all Content-Length copied onto the response the page loads forever spinner never resolves controller.close() never called two headers on the page chrome rendered twice the origin returned a full document mojibake in the body only head and tail are fine charset disagreement between sources
Each symptom maps to exactly one line of the implementation, which makes a misbehaving stream unusually quick to diagnose.

FAQ

Is this the same as server-side streaming? It achieves the same effect from the client side. If your origin can already flush early, you may not need it; if it cannot, this gets you most of the benefit without changing the server’s rendering model.

Does it work offline? Partly — the shell paints from cache, and the body needs its own offline fallback. That combination is often better than an all-or-nothing offline page, because the user at least sees the navigation.

Can I stream without navigation preload? Yes. They are independent: preload starts the body request earlier, streaming renders the shell sooner. Each is worth having alone.

What if the origin cannot return a fragment? You can still stream a full document through, but you lose the benefit of skipping the duplicated chrome — and you must not wrap a full document in your shell, or the page ends up with two headers.

How do I debug a stream that renders nothing? Check the Content-Type first, then whether the controller is ever closed. A stream that is never closed leaves the page loading forever.