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.
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);
}
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.preloadResponseisundefinedfor scripts, styles and API calls, so a handler that assumes otherwise quietly falls through tofetchand looks like preload is not working. - Enable in
activate, notinstall. 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.
setHeaderValueis 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.
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.
Related
- Navigation Preload & Streaming — the wider picture, including streamed shell composition.
- Streaming the App Shell from a Service Worker — the complementary optimisation for first paint.
- Service Worker Routing Strategies — where preload fits among cache-first, network-first and stale-while-revalidate.