Navigation Preload & Streaming

A service worker that intercepts navigation requests can make a site work offline — and, if it is written carelessly, can also make every navigation slower than it was before the service worker existed. Two standard mechanisms fix that: navigation preload removes the startup cost of waking the worker, and streamed composition lets the cached shell reach the parser while the personalised body is still in flight. Together they turn the worker from a latency tax into a rendering head start.

← Back to Service Worker & Offline Routing

The Problem

A service worker is not always running. The browser terminates it aggressively — often within thirty seconds of idleness — to reclaim memory. When a navigation arrives and the worker is not running, the browser must boot it before the fetch handler can respond: start the worker thread, evaluate the top-level script, run any registration side effects, and only then dispatch the event. On a mid-range phone that boot costs somewhere between 50 and 250 milliseconds, and it is pure overhead added in front of a network request that has not yet been issued.

The shape of the damage is worth stating precisely, because it is counter-intuitive. Without a service worker, the browser sends the request the instant the navigation starts. With a naive service worker, the sequence becomes boot the worker → run the handler → then send the request. The network time is unchanged; the boot time is added to it. A site that adds a service worker for offline support and measures a regression in Largest Contentful Paint has almost always hit exactly this.

The second problem is structural rather than temporal. A worker that responds to a navigation with a fully cached page serves stale content; a worker that responds with a network fetch serves fresh content but has to wait for all of it, including the slow personalised parts, before the browser sees a single byte. The usual compromise — cache the shell, fetch the data, render on the client — trades a fast paint for a slow, JavaScript-dependent one, and it is the pattern that makes app shell caching feel sluggish on cold loads even when the shell is instant.

Service worker boot latency with and without navigation preload Three timelines. No service worker sends the request immediately. A naive service worker boots first and then sends the request, adding the boot time. With navigation preload, the request is sent in parallel with the boot so the boot time overlaps the network wait instead of preceding it. No service worker network 330ms response at 330ms Naive service worker boot 120ms network 330ms 450ms With navigation preload boot 120ms preload request 330ms — issued by the browser, in parallel 330ms — boot is free navigation starts The network time never changes. Navigation preload moves the worker boot alongside it rather than in front of it, which is why the win is largest on exactly the devices where boot is slowest.
Navigation preload does not make the network faster — it stops the service worker's startup from being added to it.

Core API & Primitives

registration.navigationPreload. A per-registration switch. When enabled, the browser issues the navigation request itself, in parallel with booting the worker, and hands the resulting response to the fetch handler as event.preloadResponse.

// TypeScript 5.x — the navigation preload surface

interface NavigationPreloadManager {
  enable(): Promise<void>;
  disable(): Promise<void>;
  /** Value of the Service-Worker-Navigation-Preload request header. */
  setHeaderValue(value: string): Promise<void>;
  getState(): Promise<{ enabled: boolean; headerValue: string }>;
}

event.preloadResponse. A promise for the preloaded Response, present only on navigation requests and only when preload is enabled. It resolves to undefined when preload is unsupported or the request was not a navigation, so every use needs a fallback path.

The Service-Worker-Navigation-Preload header. Sent on every preload request with the value true by default. The server can branch on it — most usefully to return only the body fragment rather than a full document, since the shell is about to come from the cache anyway.

ReadableStream and TransformStream. The composition primitives. A Response constructed from a stream begins delivering bytes to the HTML parser as soon as the first chunk is enqueued, which is what allows a cached header to paint while the body is still downloading. This is the same streaming behaviour a server uses for early flush, moved into the client.

TextEncoder. Streams carry Uint8Array chunks, so any string pulled from a cached Response must be encoded before enqueueing.

A useful mental model: navigation preload optimises when the request starts, and streaming optimises when the first byte is rendered. They are independent, they compose, and each is worth deploying on its own.

One detail of the preload contract catches people out. The preload request is issued by the browser, not by your code, which means it is subject to the browser’s own credentials, redirect and CORS handling rather than to whatever options you would have passed to fetch. It follows redirects transparently, sends cookies as a same-origin navigation would, and produces an opaque redirect result if the destination crosses origins. In practice this is what you want for a document request, but it does mean the preloaded response is not always byte-identical to what a hand-written fetch(event.request) would have produced — most visibly when an authentication layer redirects unauthenticated navigations to a login page.

Step-by-Step Implementation

Prerequisite: a registered service worker that already handles install and activate, and a build that emits the page shell as two cacheable fragments — everything before the content area, and everything after it.

1. Enable navigation preload on activation

Enable it inside activate, not install, and guard on feature detection: the property is absent in Safari before 17 and in older Firefox builds.

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

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      if ("navigationPreload" in self.registration) {
        await self.registration.navigationPreload.enable();
        // Ask the origin for the body fragment only — the shell is cached locally.
        await self.registration.navigationPreload.setHeaderValue("fragment");
      }
      await self.clients.claim();
    })(),
  );
});

2. Use the preloaded response, with an ordered fallback

Inside the fetch handler, prefer the preload, fall back to a normal fetch, and fall back again to the cache when the network fails. Awaiting event.preloadResponse when preload is disabled is harmless — it resolves to undefined — so the code path is uniform.

// TypeScript 5.x — preferred source order for a navigation request

async function navigationResponse(event: FetchEvent): Promise<Response> {
  const preloaded = await event.preloadResponse;
  if (preloaded) return preloaded as Response;

  try {
    return await fetch(event.request);
  } catch {
    // Offline: fall through to the cached shell rather than failing the navigation.
    const cached = await caches.match("/offline/");
    return cached ?? new Response("Offline", { status: 503 });
  }
}

3. Compose a streamed response from cached shell plus live body

Construct a ReadableStream that enqueues the cached header immediately, pipes the network body through as it arrives, then enqueues the cached footer. The browser starts parsing on the first chunk, so the header, navigation and skeleton paint while the body is still downloading.

// 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-v1");
  const [head, tail] = await Promise.all([
    cache.match("/shell/head.html"),
    cache.match("/shell/tail.html"),
  ]);
  if (!head || !tail) return navigationResponse(event); // no shell cached yet

  const headText = await head.text();
  const tailText = await tail.text();
  const bodyResponse = await navigationResponse(event);

  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      // 1. Cached shell — reaches the parser in the first frame.
      controller.enqueue(encoder.encode(headText));

      // 2. Live body — forwarded chunk by chunk as it arrives.
      const reader = bodyResponse.body?.getReader();
      if (reader) {
        for (;;) {
          const { done, value } = await reader.read();
          if (done) break;
          controller.enqueue(value);
        }
      }

      // 3. Cached footer.
      controller.enqueue(encoder.encode(tailText));
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/html; charset=utf-8" },
  });
}

4. Route only real navigations through it

Streaming composition applies to document requests. Sub-resources, API calls and same-origin fetches from the page must keep their existing strategies, or you will wrap a JSON response in HTML.

// TypeScript 5.x — dispatch by request mode and destination

self.addEventListener("fetch", (event) => {
  const { request } = event;
  const isDocument = request.mode === "navigate" && request.destination === "document";
  if (!isDocument) return; // let the other strategies handle it

  event.respondWith(streamedDocument(event));
});

5. Keep the shell fragments fresh

A streamed shell is only correct while it matches the markup the origin expects to be wrapped around. Version the fragments with the build hash, precache them in install, and delete the previous version in activate so a deployment can never mix an old header with a new body.

Composing a streamed document from a cached shell and a live body A single output stream receives three sources in order: the cached header fragment enqueued immediately, the preloaded network body forwarded chunk by chunk, and the cached footer fragment enqueued at the end. First paint occurs after the first chunk rather than after the whole response. cache: head.html available in 0ms preload: body streamed from network cache: tail.html enqueued last ReadableStream one Response, three sources HTML parser starts on the first chunk — header and navigation paint before the body lands Version the two fragments with the build hash so a deploy can never pair an old header with a new body.
One response, three sources: the cached shell reaches the parser immediately while the preloaded body streams in behind it.

Choosing Between the Two Mechanisms

Navigation preload and streamed composition solve different halves of the same problem, and a team rarely has the budget to adopt both at once. The table below is the decision most sites actually face.

Property Navigation preload Streamed shell composition
What it removes Service worker boot time in front of the request The wait for the full body before first paint
Typical saving 50–250 ms, larger on low-end devices 100–600 ms of First Contentful Paint
Server changes required Optional — only to honour the header Yes — the origin must emit a body fragment
Build changes required None Shell must be split into head and tail fragments
Risk if misconfigured Low — an unused preload wastes one request Higher — a mismatched shell produces broken markup
Works offline No — it is a network request Partly — the shell paints, the body needs a fallback
Effort to adopt An afternoon A sprint, mostly in the build and the origin

The pragmatic order is to enable navigation preload first. It is roughly ten lines, cannot corrupt output, and delivers its entire benefit to the users on the slowest hardware — the population where boot latency is worst and where a fixed 200 ms saving matters most. Streaming is the larger win but demands that the origin and the build cooperate, so it belongs in a planned piece of work rather than a quick patch.

There is one case where the order reverses. If the site is already server-rendered in fragments — many content platforms and most template-driven stacks are — the shell split costs almost nothing, and streaming becomes the cheaper of the two. Check whether the origin can already emit a bodies-only response before assuming the harder path is hard.

A third option deserves mentioning because it is frequently mistaken for these two: skipping the fetch handler entirely for navigations. A service worker with no navigation handler imposes no boot cost, because the browser does not need the worker to answer a request it is not intercepting. If offline navigation is not a requirement, that remains the fastest configuration, and it is worth confirming that offline support is genuinely wanted before optimising the cost of providing it.

Verification & Testing

Confirm three things: that preload is actually enabled, that the handler uses it, and that the first paint happens before the body finishes.

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

test("navigation preload is enabled and used", async ({ page }) => {
  await page.goto("/");
  await page.evaluate(() => navigator.serviceWorker.ready);

  const state = await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    return reg.navigationPreload?.getState();
  });
  expect(state?.enabled).toBe(true);
  expect(state?.headerValue).toBe("fragment");

  // The preload request carries the marker header; assert the origin sees it.
  const [request] = await Promise.all([
    page.waitForRequest((r) => r.isNavigationRequest()),
    page.getByRole("link", { name: "Reports" }).click(),
  ]);
  expect(request.headers()["service-worker-navigation-preload"]).toBe("fragment");
});

The streaming behaviour is easiest to confirm in DevTools. In the Network panel, a streamed navigation shows a long “Content Download” segment with the document’s first paint occurring well before it ends; in the Performance panel, First Contentful Paint should land close to the moment the cached header is enqueued rather than at the response’s end. If FCP moves with total response time, the stream is being buffered somewhere — usually because a Content-Length header was copied onto the composed response, which forces the browser to wait for a complete body.

For the boot-cost measurement itself, compare performance.getEntriesByType("navigation")[0].workerStart with requestStart. Without preload the gap is the boot cost sitting in front of the request; with preload the two are effectively simultaneous.

Performance Tuning

Return a fragment, not a document, from the origin. Branching on the Service-Worker-Navigation-Preload header lets the server skip rendering the header, navigation and footer entirely. On a content-heavy page that removes a meaningful share of the response bytes and some of the server’s render time.

Never await the preload if you will not use it. If a route is served entirely from cache, awaiting event.preloadResponse still costs you the request. Consume or explicitly cancel it — an unconsumed preload response leaks a connection until it is garbage collected.

Flush the head early. Put the stylesheet link and preload hints in the cached header fragment. Because that fragment reaches the parser first, the browser can begin fetching CSS while the body is still in flight, which is the same head start server-side early flush provides.

Keep the shell fragments small. They are enqueued synchronously from cache; a 60 KB header delays the body’s first chunk by the time it takes to encode it. Under 10 KB each is a reasonable ceiling.

Combine with a timeout rather than a race. For flaky networks, wrapping the body source in a timeout that falls back to the cached copy keeps the paint fast without abandoning freshness — the pattern in network-first with a timeout for navigations.

Measure on a cold worker. The whole benefit is invisible when the worker is already running. Stop the worker in DevTools’ Application panel before each measurement, or your numbers will show no improvement at all.

The order in which to adopt preload and streaming Four adoption steps ordered by effort and risk: enabling navigation preload, adding the origin fragment branch, splitting the shell in the build, and finally composing the streamed response. Adoption order for the two mechanisms effort risk if wrong 1 · navigation preload about ten lines low — an unused preload wastes one request 2 · origin fragment branch one server-side condition low — falls back to a full document 3 · split the shell in the build a build change medium — a bad shell breaks markup 4 · streamed composition a worker rewrite higher — no recovery after a flush
Each step is useful on its own, which is what makes this order safe to stop partway through.

Gotchas & Failure Modes

  • Preload only applies to navigations. Sub-resource requests never populate event.preloadResponse; a handler that assumes otherwise silently falls back to fetch and looks like preload is not working.
  • An unconsumed preload response wastes bandwidth. If you decide to serve from cache, the request has already gone out. Design the handler so the cache-only decision is made before enabling preload for that route, or accept the cost knowingly.
  • setHeaderValue requires a server that understands it. Sending fragment to an origin that ignores it returns a full document, which then gets wrapped in your cached shell — producing a page with two headers.
  • Streamed responses cannot carry Content-Length. Copying headers wholesale from the network response onto the composed one is the most common cause of a stream that buffers instead of streaming.
  • Errors mid-stream are unrecoverable. Once bytes are flushed you cannot switch to an error page; wrap the body read in a try/catch and enqueue an inline error block plus the footer instead of letting the stream reject.
  • Safari support arrived late. Feature-detect rather than assuming; on unsupported browsers the fallback path must be a complete, working strategy rather than a degraded one.
  • Shell and body must agree on encoding. Mixing a UTF-8 encoded shell with a body served in another charset produces mojibake only on the body, which reads as a data bug rather than a service worker bug.