Enabling Navigation Preload in a Service Worker

After reading this you will be able to enable navigation preload, consume the preloaded response correctly, and confirm that your service worker’s boot time no longer sits in front of every navigation request.

← Back to Navigation Preload & Streaming

Prerequisites

Core Concept

The browser terminates an idle service worker aggressively, often within thirty seconds. When a navigation arrives afterwards, it must start the worker thread and evaluate the top-level script before the fetch handler can run. On mid-range hardware that boot costs 50–250 milliseconds, and it is added in front of a network request that has not started yet.

Navigation preload changes the ordering. With it enabled, the browser issues the navigation request itself, in parallel with booting the worker, and hands the resulting Response to the handler as event.preloadResponse. The network time is unchanged; the boot time now overlaps it instead of preceding it. That is the entire feature, and the reason it is the highest-value ten lines in most service workers.

Three details make the difference between a correct implementation and a subtly wrong one.

event.preloadResponse is a promise that may resolve to undefined — on non-navigation requests, on browsers without support, and when preload is disabled. Every consumer needs the fallback path.

An unconsumed preload response wastes a request. The browser has already sent it; deciding afterwards to serve from cache means you paid for bytes you discarded. Either consume it or accept the cost knowingly.

The request is issued by the browser, not by you, so it follows the browser’s redirect, credential and CORS rules for a navigation rather than any options you would have passed to fetch. That is usually what you want for a document, but it means the preloaded response is not always byte-identical to fetch(event.request) — most visibly when an auth layer redirects.

Where the worker boot sits, with and without preload Two timelines from the start of a navigation. Without preload, the worker boots for one hundred and twenty milliseconds and only then issues a three hundred and thirty millisecond request, totalling four hundred and fifty. With preload, both start together and the total is three hundred and thirty. navigation starts without preload boot 120ms network 330ms 450ms with preload boot 120ms preload request 330ms — browser-issued, in parallel 330ms The saving equals the boot time, so it is largest on exactly the devices that need it most.
Preload does not speed up the network — it stops the worker's startup from being charged to the user in front of it.

Implementation

Enable inside activate, behind a feature check, and hold the event open until it completes.

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

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // Absent in Safari before 17 and older Firefox — never assume it exists.
      if ("navigationPreload" in self.registration) {
        await self.registration.navigationPreload.enable();
        // Optional: a header the origin can branch on to skip rendering chrome.
        await self.registration.navigationPreload.setHeaderValue("document");
      }
      await self.clients.claim();
    })(),
  );
});

Consuming it needs an ordered fallback: preload, then a normal fetch, then the cache.

// TypeScript 5.x — one function, three sources, in priority order

async function handleNavigation(event: FetchEvent): Promise<Response> {
  // Resolves to undefined when preload is unsupported or disabled — harmless.
  const preloaded = (await event.preloadResponse) as Response | undefined;
  if (preloaded) return preloaded;

  try {
    return await fetch(event.request);
  } catch {
    // Offline: serve the cached shell so the navigation still completes.
    const cached = await caches.match("/offline/");
    return cached ?? new Response("You are offline", {
      status: 503,
      headers: { "Content-Type": "text/plain; charset=utf-8" },
    });
  }
}

self.addEventListener("fetch", (event) => {
  // Preload only ever applies to document navigations.
  if (event.request.mode !== "navigate") return;
  event.respondWith(handleNavigation(event));
});

If a route is served entirely from cache, the preload has already been sent and its body must be released — otherwise the connection stays open until garbage collection:

// TypeScript 5.x — release a preload you have decided not to use

async function cacheFirstNavigation(event: FetchEvent, cacheKey: string): Promise<Response> {
  const cached = await caches.match(cacheKey);

  if (cached) {
    // Consume and discard so the connection is not held open.
    event.preloadResponse
      .then((r) => (r as Response | undefined)?.body?.cancel())
      .catch(() => {});
    return cached;
  }

  return handleNavigation(event);
}

Finally, if you set a header value, the origin should honour it — otherwise you send a hint nobody reads:

// TypeScript 5.x — origin-side branch (Cloudflare Worker / Node middleware shape)

export function handleRequest(request: Request): Response {
  const preloadHint = request.headers.get("Service-Worker-Navigation-Preload");
  // "document" → the full page; a different value could mean "body fragment only".
  return preloadHint === "fragment" ? renderBodyFragment(request) : renderFullDocument(request);
}
The three-source fallback chain for a navigation request A navigation request first tries the preloaded response, falls back to a normal network fetch if preload is unavailable, and falls back again to the cached offline shell if the network fails. A separate branch shows a cache-first route cancelling the unused preload body. mode === "navigate" everything else: skip 1 · preloadResponse may be undefined 2 · fetch(request) unsupported browsers 3 · cached shell offline Cache-first branch — the preload was still sent The browser issued the request before your handler decided anything. Cancel the body so the connection is released: preloadResponse.then(r => r?.body?.cancel())
Preload first, network second, cache third — and if you take the cache branch, release the preload body you already paid for.

Verification

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

test("navigation preload is enabled and reaches the origin", 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 ? reg.navigationPreload.getState() : null;
  });
  expect(state?.enabled).toBe(true);
  expect(state?.headerValue).toBe("document");

  const [request] = await Promise.all([
    page.waitForRequest((r) => r.isNavigationRequest()),
    page.getByRole("link", { name: "Reports" }).click(),
  ]);
  expect(request.headers()["service-worker-navigation-preload"]).toBe("document");
});

The measurement that proves the benefit uses the Navigation Timing entry. Stop the worker first in DevTools → Application → Service Workers, otherwise the worker is already running and there is nothing to save:

// Run in the console after a navigation with the worker cold.
const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming;
console.log({
  workerStart: nav.workerStart,   // when the worker began booting
  requestStart: nav.requestStart, // when the request went out
  gap: nav.requestStart - nav.workerStart, // ≈0 with preload, ≈boot time without
});

One more measurement is worth taking before and after, because it is the number that justifies the change to anyone who asks: the median gap between workerStart and requestStart across real sessions, reported from the field rather than from a lab run. Lab numbers on a developer machine understate the benefit badly — a fast desktop boots a worker in perhaps 30 milliseconds, while the low-end phones in the tenth percentile of your traffic can take five times that. Sending the gap as a custom metric alongside your existing Core Web Vitals reporting costs a few lines and turns an argument about whether the optimisation is worth shipping into a distribution anyone can read.

Gotchas

  • Preload never applies to sub-resources. event.preloadResponse is undefined for scripts, styles and API calls, so a handler that assumes otherwise quietly falls through to fetch and looks like preload is not working.
  • Enable in activate, not install. The registration is not yet controlling clients during install, and the call is wasted.
  • An unconsumed preload is a real cost. Cancel the body on any branch that does not use it.
  • setHeaderValue is pointless without an origin that reads it. Sending a hint nobody honours costs a header and gains nothing.
  • Measure with a cold worker. With the worker already running there is no boot to overlap, and the numbers will show no improvement at all.
  • Authenticated redirects behave differently. The browser follows them as a navigation would, so the preloaded response may be the login page rather than the requested route.
Four cases where the preloaded response is absent Four situations classified by why the preload response is undefined and what the handler should do: sub-resource requests, preload never enabled, an unsupported browser, and the case where it is in fact present. Where event.preloadResponse resolves to undefined why what to do a sub-resource request preload is navigation-only fall through to fetch preload never enabled the activate call was skipped feature-detect and enable an unsupported browser the property does not exist the fallback IS the strategy the worker was already running still populated — this one works no action
Three of the four are normal and one is a bug — which is why the fallback path has to be a complete strategy rather than a stub.

FAQ

Does this help if my service worker does not intercept navigations? No, and you do not need it. A 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.

Is it safe to enable unconditionally? Yes, behind the feature check. On unsupported browsers the property is absent and the fallback path is a normal fetch, which is what would have happened anyway.

Does it work offline? No — it is a network request. Offline navigations fall through to the cache branch, which is why that branch must be a complete strategy rather than an afterthought.

Will it double-fetch? Only if you both consume the preload and issue your own fetch. Preferring the preload and falling back only when it is undefined avoids this entirely.

How much does it actually save? The worker’s boot time, typically 50–250 ms and worst on low-end devices. It is one of the few optimisations whose benefit is largest for the users with the slowest hardware.