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.
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" },
});
}
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-Lengthwill 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
catchabove 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.
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.
Related
- Navigation Preload & Streaming — the pairing of these two optimisations and when each is worth the effort.
- Enabling Navigation Preload in a Service Worker — starting the body request before the worker has booted.
- App Shell Caching — precaching the fragments this technique stitches together.