Navigation Preload¶
Navigation preload lets the browser start a navigation's network request at the same time as it boots your service worker, instead of waiting for the worker to start and run its fetch handler first. The worker then picks up the in-flight response from event.preloadResponse. For sites that answer navigations from the network (network-first, or streamed pages), it removes worker start-up from the most latency-sensitive request you have, and it is supported in Chromium, Firefox and Safari.
Key takeaways
- Without preload, a cold service worker delays every navigation it handles: web.dev puts boot-up at usually around 50 ms, more like 250 ms on mobile, and over 500 ms in extreme cases.
- Enable it with
self.registration.navigationPreload.enable()in theactivateevent, then useawait event.preloadResponseinstead offetch(event.request)for navigations. - The preload request is the navigation request plus a
Service-Worker-Navigation-Preloadheader (default valuetrue, changeable withsetHeaderValue()); servers that vary their response on it must sendVary: Service-Worker-Navigation-Preload. - Preload only applies to
GETnavigations (pages and iframes), and its state lives on the registration, so it persists across worker updates until something callsdisable(). - If you enable it and then answer from the cache without touching
preloadResponse, you waste a request and Chromium warns; if you also callfetch(event.request), you make the request twice. - It is the wrong tool for app-shell sites that always answer navigations from the cache.
- Supported in Chrome 59, Firefox 99 and Safari 15.4 and later.
The problem: worker start-up blocks navigations¶
When a page is controlled by a service worker with a fetch listener, the browser cannot send the navigation request until the worker has decided what to do with it. If the worker is not running, the browser must first start it: create a thread, evaluate your script, run any top-level code, and then dispatch the fetch event. Only then can your handler call fetch().
sequenceDiagram
participant User
participant Browser
participant SW as Service worker
participant Server
User->>Browser: click link
Browser->>SW: start worker
Note over SW: thread start, script evaluation
Browser->>SW: dispatch fetch event
SW->>Server: fetch(event.request)
Server-->>SW: HTML
SW-->>Browser: respondWith(response)
Browser-->>User: render Jake Archibald's web.dev article that introduced the feature says boot-up is "usually around 50ms", "more like 250ms" on mobile, and "in extreme cases (slow devices, CPU in distress) it can be over 500ms". Because the browser keeps a worker alive for a while between events, you only pay it occasionally, but "occasionally" is exactly the first navigation of a visit: arriving from a search result, opening a bookmark, tapping a home-screen icon. Chromium stops an idle worker after about 30 seconds without events, so most first navigations of a session start cold.
Falling through does not avoid the cost. Even if your handler returns without calling respondWith(), the browser still has to start the worker and dispatch the event before it knows that. The only ways to take start-up off the critical path are to not have a fetch listener, to exclude the URL with the Static Routing API, or to overlap start-up with the network request, which is what navigation preload does.
Where the time goes on a cold navigation without preload:
| Phase | Depends on | Overlapped by preload? |
|---|---|---|
| Worker thread and script start-up | Device CPU, script size, top-level work | Yes |
fetch event dispatch | Number of listeners, code before respondWith() | Yes |
| Your handler's work before the network request (cache lookups, IndexedDB reads) | Your code | Yes, if you start from preloadResponse |
| DNS, connection, TLS, request, server think time, first byte | Network and server | This is the preload |
How navigation preload works¶
With preload enabled, the browser sends the navigation request itself, in parallel with starting the worker, and hands the eventual response to the worker as a promise.
sequenceDiagram
participant User
participant Browser
participant SW as Service worker
participant Server
User->>Browser: click link
par in parallel
Browser->>SW: start worker
and
Browser->>Server: GET /page with Service-Worker-Navigation-Preload
end
Browser->>SW: dispatch fetch event with preloadResponse
Server-->>Browser: HTML headers and body
SW->>SW: "await event.preloadResponse"
SW-->>Browser: respondWith(preloaded)
Browser-->>User: render When the browser sends a preload request¶
The Service Workers specification's Handle Fetch algorithm sends a preload request only when all of these are true:
- The request is a navigation request (a document or iframe navigation).
- Its method is
GET. - The registration's active worker handles
fetchevents (its set of event types to handle containsfetch). - That worker's
fetchlisteners are not all empty. Chromium's intent to ship for skipping no-op handlers states that "Navigation Preload is ignored for the no-op fetch handler." - The registration's navigation preload enabled flag is set.
- No static routing rule already decided the request's fate. A
networkorcacherule answers the request before the preload step runs, and therace-network-and-fetch-handlersource races its own network request and resolvespreloadResponsewithundefined.
Subresource requests, POST form submissions and navigations while preload is disabled get preloadResponse resolved with undefined. Navigations outside the worker's scope and hard reloads (shift+reload) never reach the worker at all, so neither a preload nor a fetch event happens.
What the preload request is¶
The spec builds the preload request by cloning the navigation request, appending a Service-Worker-Navigation-Preload header with the registration's header value, and setting its service-workers mode to "none" so it cannot loop back into the worker. Being a clone has consequences:
- Same URL, cookies and credentials as the navigation. Authentication works exactly as it would without a worker.
- Same HTTP cache mode. The preload goes through the browser's HTTP cache like the navigation would, so a fresh cached document can satisfy it without a network trip, and a reload revalidates.
- Same redirect mode,
manual. A server redirect is not followed. Chromium resolvespreloadResponsewith a response of typeopaqueredirect(status0,ok === false), which you pass straight torespondWith(); the browser follows it and the target URL gets its ownfetchevent and its own preload. - Tied to the navigation. If the user cancels the navigation, the spec aborts the preload too.
- Errors reject. If the preload hits a network error (offline, DNS failure),
preloadResponserejects with aTypeError. HTTP errors (404, 500) are ordinary responses.
The worker does not have to be running for the preload to start, and the spec does not skip the preload when the worker happens to be running already. You pay one network request per qualifying navigation, whether or not your handler uses it.
What event.preloadResponse contains¶
| Situation | event.preloadResponse |
|---|---|
Preload enabled, GET navigation, response received | Promise fulfilled with a Response (basic, or opaqueredirect for redirects in Chromium) |
| Preload enabled, network error | Promise rejected with TypeError |
Preload disabled, not a navigation, not GET, or race-network static route | Promise fulfilled with undefined |
| Engine without navigation preload | The property does not exist (undefined) |
await event.preloadResponse handles the last three cases uniformly: awaiting undefined gives undefined. That is why (await event.preloadResponse) ?? fetch(event.request) works everywhere.
Relationship to automatic optimizations¶
The specification also allows a user agent, when navigation preload is not enabled, to speculatively dispatch the navigation request while the worker starts ("A user agent may speculatively dispatch a network request in parallel with creating a fetch event in order to minimize the bootstrap cost"), and to use that response if the handler calls fetch(event.request) or falls back to the network. Chromium's implementation of this idea, ServiceWorkerAutoPreload, is described on Chrome Platform Status as an optional browser optimization for main-resource GET requests, with Chrome 140 as its shipping milestone, when the ServiceWorkerAutoPreloadEnabled enterprise policy became available to administrators, and Chrome 154 (stable since September 22, 2026) as the milestone in which that policy is removed. How it differs from the real thing:
| Navigation preload | ServiceWorkerAutoPreload | |
|---|---|---|
| Who turns it on | You, with enable() | The browser, at its discretion |
| Visible to your handler | event.preloadResponse | Invisible: preloadResponse is undefined; the response is used only if you call fetch() with a request equal to event.request, or fall back |
| Visible to your server | Service-Worker-Navigation-Preload header | No distinguishing header |
| Response you can tailor | Yes (fragments, deltas) | No: it must be the same response a plain navigation would get |
| When it is skipped | Non-GET, non-navigation, no fetch listener, no-op listeners | The same cases, plus whenever navigation preload is enabled or a static route matches with source fetch-event |
The explainer documents that last row as the opt-out: register a static route that sends every URL to fetch-event, and the spec's condition ("if the worker matched router source is not fetch-event") prevents the automatic request. Explicit navigation preload remains the portable, controllable option: it works in Firefox and Safari too, and lets your server recognize and tailor preload requests.
The NavigationPreloadManager API¶
The manager is available as registration.navigationPreload, both in the worker (self.registration.navigationPreload) and in pages ((await navigator.serviceWorker.ready).navigationPreload). It requires a secure context.
[SecureContext, Exposed=(Window,Worker)]
interface NavigationPreloadManager {
Promise<undefined> enable();
Promise<undefined> disable();
Promise<undefined> setHeaderValue(ByteString value);
Promise<NavigationPreloadState> getState();
};
dictionary NavigationPreloadState {
boolean enabled = false;
ByteString headerValue;
};
| Method | Resolves with | Rejects with | Effect |
|---|---|---|---|
enable() | undefined | InvalidStateError if the registration has no active worker | Sets the registration's navigation preload enabled flag |
disable() | undefined | InvalidStateError if no active worker | Unsets the flag |
setHeaderValue(value) | undefined | TypeError if value is not a valid header value after normalization; InvalidStateError if no active worker | Sets the header value sent with future preload requests |
getState() | { enabled, headerValue } | Never | Reads both values |
Enable it in activate, not install¶
enable(), disable() and setHeaderValue() reject with InvalidStateError when the registration's active worker is null. During a first installation there is no active worker yet, so calling enable() in install fails. By the time activate fires, the activating worker already is the registration's active worker, so activate is the right place:
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Feature-detect: undefined in engines without navigation preload.
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
// ...clean up old caches, etc.
})(),
);
});
Two timing details follow from the spec's algorithms:
- A navigation that starts during activation may miss the preload. Handle Fetch reads the registration's preload flag before it waits for an
activatingworker to becomeactivated. A navigation that arrives while youractivatehandler is still running is judged by the flag's old value, so it gets no preload even though thefetchevent itself waits for activation. Navigations that start afterenable()has resolved get one. Keepactivateshort and callenable()first, before slower work such as cache cleanup. enable()ininstallfails only sometimes. On an update, the old worker is still the registration's active worker while the new one installs, soenable()succeeds; on a first install it rejects. Code that enables preload ininstalltherefore works whenever you test an update and fails for every new visitor. If that rejected promise is passed toevent.waitUntil(), the whole installation fails and the worker is discarded. Useactivate.
setHeaderValue(): telling the server what you already have¶
By default the preload request carries Service-Worker-Navigation-Preload: true. setHeaderValue() replaces the value for all future preload requests. The value is a ByteString: the spec normalizes it (strips leading and trailing whitespace) and rejects values that are not valid header values (for example, containing a newline or NUL) with a TypeError. An empty string is a valid header value. Non-Latin-1 characters cannot be represented in a ByteString, so the call throws a TypeError before it even runs; encode such data first.
Useful values communicate the client's state so the server can send less:
- A shell or template version (
"shell-2026-09-25"), so the server knows which partials the cached shell expects. - The ID or timestamp of the newest item the client has cached, so a feed page can return only newer entries, the example the web.dev article gives.
- A mode flag (
"partial"), so the server returns only the page body for stream composition.
const SHELL_VERSION = "shell-2026-09-25";
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const preload = self.registration.navigationPreload;
if (!preload) return;
await preload.enable();
await preload.setHeaderValue(`partial;v=${SHELL_VERSION}`);
})(),
);
});
You can also change the value later, from the worker or from a page, whenever the state it describes changes. The value is stored on the registration, not in the worker's memory, so it survives the worker being stopped and restarted.
getState() and the lifetime of the setting¶
getState() returns { enabled, headerValue } and never rejects. Both values live on the registration, which has an important consequence: they persist across service worker updates. If version 1 of your worker enabled preload and version 2 no longer uses preloadResponse, preload stays enabled, every navigation sends a request nobody reads, and Chromium logs a cancellation warning each time. A worker that does not use preload should say so explicitly:
self.addEventListener("activate", (event) => {
event.waitUntil(self.registration.navigationPreload?.disable());
});
From a page, the same API is handy for diagnostics and for experiments:
async function navigationPreloadStatus() {
if (!("serviceWorker" in navigator)) return "no-service-worker";
const registration = await navigator.serviceWorker.ready;
if (!registration.navigationPreload) return "unsupported";
const { enabled, headerValue } = await registration.navigationPreload.getState();
return enabled ? `enabled (${headerValue})` : "disabled";
}
navigationPreloadStatus().then((status) => console.info("Navigation preload:", status));
Using preloadResponse in your fetch handler¶
Enabling preload only starts the request. Your handler must consume event.preloadResponse, or the work is wasted.
The minimal correct pattern¶
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(
(async () => {
try {
// A Response when preload ran, undefined otherwise.
const preloaded = await event.preloadResponse;
if (preloaded) return preloaded;
// Preload unsupported, disabled, or not applicable (e.g. POST).
return await fetch(event.request);
} catch {
// The preload or the fetch failed: offline, DNS, TLS...
return (await caches.match("/offline.html")) ?? Response.error();
}
})(),
);
});
Two properties make this correct. It never calls fetch(event.request) when a preload response exists, so there is exactly one network request. And it awaits preloadResponse inside the promise passed to respondWith(), which keeps the event (and the preload) alive.
Answering from the cache without cancelling the preload¶
If your strategy sometimes answers navigations from Cache Storage, for example a stale-while-revalidate page cache, you must still let the preload settle. If the event finishes while preloadResponse is pending, the browser may cancel the preload, and Chromium logs:
The service worker navigation preload request was cancelled before 'preloadResponse' settled. If you intend to use 'preloadResponse', use waitUntil() or respondWith() to wait for the promise to settle.
MDN's example handles it by registering the preload promise with waitUntil() (event.waitUntil(preloadResponsePromise.catch(() => undefined))). The version below goes one step further and uses the preloaded copy to refresh the cache:
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(
(async () => {
// Promise.resolve() also covers engines where preloadResponse does not exist.
const preloadPromise = Promise.resolve(event.preloadResponse);
const cached = await caches.match(event.request);
if (cached) {
// Keep the navigation preload request alive even though we do not use it
// for this response; here we also refresh the cache with it.
event.waitUntil(
(async () => {
const fresh = await preloadPromise.catch(() => undefined);
if (fresh?.ok) {
const cache = await caches.open("pages");
await cache.put(event.request, fresh);
}
})(),
);
return cached;
}
return (await preloadPromise) ?? fetch(event.request);
})(),
);
});
Using the preload response to update the cache turns the "wasted" request into a stale-while-revalidate refresh. If your handler always answers navigations from the cache and never needs the network copy, disable preload instead: you are paying for a request on every navigation.
Redirects, errors and caching¶
- Do not treat
!response.okas a failure. A redirected preload arrives asopaqueredirectwithok === falseand must be returned as is; 404 and 500 pages are real responses the user should see. - A rejection means the network failed. That is the moment for cached copies and offline pages.
- Clone before caching. The body can be read once; clone synchronously before returning the response, and only cache
okresponses of typebasic. - Never follow up with
fetch(event.request). Once a preload response exists, a second fetch doubles server load and can return a different answer (think of a page that consumes a one-time token).
A complete worker using preload¶
const PAGES_CACHE = "pages";
const OFFLINE_URL = "/offline.html";
const NETWORK_TIMEOUT_MS = 4000;
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(PAGES_CACHE).then((cache) => cache.add(new Request(OFFLINE_URL, { cache: "reload" }))),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.mode !== "navigate" || request.method !== "GET") return;
event.respondWith(handleNavigation(event));
});
async function handleNavigation(event) {
const { request } = event;
const cache = await caches.open(PAGES_CACHE);
// One network request: the preload if it exists, otherwise a normal fetch.
const network = Promise.resolve(event.preloadResponse).then(
(preloaded) => preloaded ?? fetch(request),
);
// Refresh the page cache in the background with successful responses.
const refresh = network.then(async (response) => {
if (response.ok && response.type === "basic") {
await cache.put(request, response.clone());
}
});
// Covers the preload and the cache write, even if the timeout wins below.
event.waitUntil(refresh.catch(() => {}));
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(resolve, NETWORK_TIMEOUT_MS);
});
try {
const winner = await Promise.race([network, timeout]);
if (winner) return winner; // the network (or preload) answered in time
// Slow network: prefer a cached copy, otherwise keep waiting.
return (await cache.match(request)) ?? (await network);
} catch {
return (
(await cache.match(request)) ??
(await cache.match(OFFLINE_URL)) ??
new Response("Offline", { status: 503, headers: { "Content-Type": "text/plain" } })
);
} finally {
clearTimeout(timer);
}
}
The refresh promise runs its .then() callback before the race settles, so response.clone() is taken before the page starts reading the body. The same shape appears as the navigation route of the router on Handling Fetch Events.
Server-side handling¶
To your server, a preload request looks like the normal navigation request plus one header:
GET /articles/navigation-preload HTTP/2
Host: www.example.com
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Cookie: session=...
Service-Worker-Navigation-Preload: true
A server that ignores the header returns the same full page it always does, and preload still works. Using the header is optional, but it enables two things: sending less (only the part the worker cannot supply from cache) and sending something smarter (only what changed since the version named in the header value).
Always send Vary when the response depends on the header¶
If your response differs based on Service-Worker-Navigation-Preload, you must tell every cache between you and the browser:
HTTP/2 200
Content-Type: text/html; charset=utf-8
Vary: Service-Worker-Navigation-Preload
Cache-Control: private, max-age=0, must-revalidate
Without Vary, a shared cache (CDN, reverse proxy) or the browser's own HTTP cache can store a body-only fragment under the page's URL and later serve it to a navigation that did not go through the worker: a hard reload (which bypasses the worker), a visitor whose worker is not installed yet, a search engine crawler, or a browser without service worker support. The result is an unstyled fragment where a full page should be. Keep Vary on the full-page response too, so caches store both variants separately.
Returning partial content¶
A common design: the worker holds the page's static header and footer in Cache Storage, and the server returns only the unique content when it sees the header. The response is smaller and the server does less templating, and the worker can stream the cached header to the user before the content arrives (see the next section).
import express from "express";
const app = express();
const PRELOAD_HEADER = "Service-Worker-Navigation-Preload";
// Every HTML response varies on the header, full page or fragment.
app.use((req, res, next) => {
res.vary(PRELOAD_HEADER);
next();
});
function wantsPartial(req) {
// Header values set with setHeaderValue(), e.g. "partial;v=shell-2026-09-25".
const value = req.get(PRELOAD_HEADER) ?? "";
return value.startsWith("partial");
}
app.get("/articles/:slug", async (req, res, next) => {
try {
const article = await loadArticle(req.params.slug);
if (!article) {
res.status(404);
return res.type("html").send(wantsPartial(req) ? notFoundFragment() : notFoundPage());
}
const body = renderArticleBody(article);
res.set("Cache-Control", "private, max-age=0, must-revalidate");
res.type("html").send(wantsPartial(req) ? body : renderFullPage({ title: article.title, body }));
} catch (error) {
next(error);
}
});
// loadArticle, renderArticleBody, renderFullPage, notFoundFragment and
// notFoundPage are your application's data and template functions.
app.listen(3000);
# Cache full pages and preload fragments as separate entries.
# $http_service_worker_navigation_preload is the request header value
# (empty for normal navigations).
proxy_cache_path /var/cache/nginx/pages keys_zone=pages:10m max_size=1g;
server {
listen 443 ssl;
server_name www.example.com;
location /articles/ {
proxy_pass http://app_backend;
proxy_cache pages;
proxy_cache_key "$scheme$host$request_uri|$http_service_worker_navigation_preload";
# The application sets Vary: Service-Worker-Navigation-Preload itself,
# so downstream caches and browsers also keep the variants apart.
}
}
The header is sent by the browser, but anything can send it: curl -H "Service-Worker-Navigation-Preload: partial" works just as well. Treat it as a rendering hint only, never as an authentication or authorization signal.
Choosing between full pages and fragments¶
| Server returns | Worker does | Best for |
|---|---|---|
| Full page, ignores the header | Returns preloadResponse as is | Most sites; zero server changes |
Full page, Vary set, header used for small tweaks | Returns preloadResponse as is | Analytics that distinguish preload traffic |
| Body fragment when the header is present | Streams cached header + fragment + cached footer | Content sites that want an instant shell and minimal bytes |
| Delta since the version in the header value | Merges with cached data (often via IndexedDB) | Feeds and timelines |
Combining preload with streaming and cache fallbacks¶
Preload pairs naturally with streamed responses: the worker sends the cached page header immediately, then pipes the preloaded fragment, then the cached footer. Because the preload started when the navigation did, the fragment often arrives while the header is still being parsed.
const SHELL_CACHE = "shell-2026-09-25";
function fragmentRequest(request) {
// Used when preload is unavailable: ask for the same fragment explicitly.
return new Request(request.url, {
headers: { "Service-Worker-Navigation-Preload": "partial;v=shell-2026-09-25" },
credentials: "include",
});
}
async function streamedNavigation(event) {
let content;
try {
content = (await event.preloadResponse) ?? (await fetch(fragmentRequest(event.request)));
} catch {
content = await caches.match("/partials/offline-content.html", { cacheName: SHELL_CACHE });
}
// A redirect cannot be streamed into a 200 page: give it to the browser as is.
if (content?.type === "opaqueredirect") return content;
const parts = [
caches.match("/partials/shell-start.html", { cacheName: SHELL_CACHE }),
Promise.resolve(content),
caches.match("/partials/shell-end.html", { cacheName: SHELL_CACHE }),
];
const { readable, writable } = new TransformStream();
const done = (async () => {
try {
for (const part of parts) {
const response = await part;
if (response?.body) {
await response.body.pipeTo(writable, { preventClose: true });
}
}
await writable.close();
} catch (error) {
await writable.abort(error).catch(() => {});
}
})();
event.waitUntil(done); // keep the worker alive until the last byte
return new Response(readable, {
// Reflect server errors (404, 410, 500...) so analytics and crawlers see them.
status: content && content.status >= 400 ? content.status : 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
self.addEventListener("fetch", (event) => {
if (event.request.mode === "navigate" && event.request.method === "GET") {
event.respondWith(streamedNavigation(event));
}
});
This version waits for the fragment's headers before committing, because the response status and redirects cannot be changed after streaming starts. That wait is short: the preload request left at navigation start, so its headers usually arrive around the time the worker is ready. If you prefer to stream the shell before the fragment's headers are known, you must accept a fixed 200 status and handle redirects inside the fragment (for example with a small script), which is harder to get right. The shell partials here are precached under a versioned cache name that matches the header value, so the server always renders a fragment compatible with the cached shell. Streaming Responses covers composition patterns in more depth.
Navigation preload with Workbox¶
Workbox has a workbox-navigation-preload module with enable(headerValue?), disable() and isSupported(). enable() registers its own activate listener that calls self.registration.navigationPreload.enable() and, if you pass a value, setHeaderValue(). On the consuming side, Workbox's shared StrategyHandler.fetch() checks whether the request is a navigation (request.mode === "navigate"), the event is a FetchEvent and event.preloadResponse exists, awaits it, and returns the preloaded response if there is one, so strategies that go to the network (NetworkFirst, NetworkOnly, StaleWhileRevalidate) use preload automatically.
One consequence of that implementation is easy to miss: StrategyHandler.fetch() returns the preloaded response before it runs the requestWillFetch, fetchDidFail and fetchDidSucceed plugin callbacks. A plugin that rewrites the outgoing navigation request or inspects the network response in fetchDidSucceed is silently bypassed for preloaded navigations. Cache-related callbacks such as cacheWillUpdate still run, because fetchAndCachePut() caches whatever fetch() returned.
import * as navigationPreload from "workbox-navigation-preload";
import { precacheAndRoute, matchPrecache } from "workbox-precaching";
import { registerRoute, NavigationRoute, setCatchHandler } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
precacheAndRoute(self.__WB_MANIFEST);
// Registers an activate listener that enables preload where supported.
navigationPreload.enable();
// handler.fetch() inside NetworkFirst awaits event.preloadResponse for
// navigations, so there is no second request.
registerRoute(
new NavigationRoute(
new NetworkFirst({ cacheName: "pages", networkTimeoutSeconds: 4 }),
),
);
setCatchHandler(async ({ request }) => {
if (request.destination === "document") {
return (await matchPrecache("/offline.html")) ?? Response.error();
}
return Response.error();
});
import { precacheAndRoute, createHandlerBoundToURL } from "workbox-precaching";
import { registerRoute, NavigationRoute } from "workbox-routing";
precacheAndRoute(self.__WB_MANIFEST);
// Every navigation is answered from the precached shell, so a preload
// response would never be used: leave navigation preload disabled.
registerRoute(
new NavigationRoute(createHandlerBoundToURL("/index.html"), {
denylist: [/^\/api\//, /^\/admin\//],
}),
);
The Workbox documentation makes the second point explicitly: developers who already answer navigations with precached HTML "do not need to enable navigation preload". The same page still says Chrome is the only supporting browser; that note predates Firefox 99 and Safari 15.4, and the module's runtime check (isSupported()) enables preload in all three engines. Workbox issue 2178, titled with the Chrome warning itself, was reported against Workbox 4.3.1 with a StaleWhileRevalidate navigation route. In current Workbox, the configuration that still leaves the preload unconsumed is a CacheFirst or CacheOnly navigation route with preload enabled: on a cache hit the strategy returns without calling handler.fetch(), so nothing awaits preloadResponse. More on combining Workbox modules is in Workbox Fundamentals and Advanced Workbox.
When navigation preload is the wrong tool¶
| Situation | Recommendation |
|---|---|
| Navigations are answered network-first or with streamed network content | Enable preload. This is the case it was designed for. |
| Navigations are always answered from a precached app shell | Do not enable it; every preload is a wasted request. Keep the worker small instead. |
| Some paths are app shell, others network-first | Enable it, and in cache-first branches either use the preload to refresh the cache or accept the waste. Consider narrowing the worker's scope. |
| The worker exists only for push or offline fallbacks | Consider removing the fetch listener entirely, or route navigations with the Static Routing API network source so the worker never starts for them. |
| Heavy server-rendered pages and constrained server capacity | Preload does not add requests compared with network-first, but it does turn cache-served navigations into server hits; measure first. |
The static routing alternative deserves a note: a race-network-and-fetch-handler rule races a network request against your handler for matching requests, which also hides start-up cost, but for those requests preloadResponse resolves to undefined, so do not combine the two for the same URLs.
Measuring the gain¶
Navigation preload helps only cold-start navigations, so averages dilute the effect. Measure it as an experiment.
Run it as an A/B test¶
Let the worker assign itself to a cohort on activation, and let pages report the cohort together with their navigation timing. getState() is available to pages, which makes the cohort observable without any extra messaging.
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const preload = self.registration.navigationPreload;
if (!preload) return;
// Sticky until the next worker version activates.
if (Math.random() < 0.5) {
await preload.enable();
} else {
await preload.disable();
}
})(),
);
});
// The fetch handler uses (await event.preloadResponse) ?? fetch(request),
// so it works identically in both cohorts.
async function reportNavigation() {
const [nav] = performance.getEntriesByType("navigation");
if (!nav) return;
let cohort = "uncontrolled";
if (navigator.serviceWorker?.controller) {
const registration = await navigator.serviceWorker.ready;
cohort = registration.navigationPreload
? (await registration.navigationPreload.getState()).enabled
? "preload-on"
: "preload-off"
: "unsupported";
}
const payload = {
cohort,
// Time to first byte of the document, relative to navigation start. For a
// controlled page it includes worker start-up and your fetch handler.
ttfb: Math.round(nav.responseStart),
// 0 when no service worker was involved; otherwise when the browser began
// starting the worker (or dispatching, if it was already running).
workerStart: Math.round(nav.workerStart),
type: nav.type, // navigate, reload, back_forward
};
navigator.sendBeacon("/rum/navigation", JSON.stringify(payload));
}
addEventListener("load", () => setTimeout(reportNavigation, 0), { once: true });
Compare the TTFB and LCP distributions of preload-on and preload-off at the 75th and 95th percentiles, split by device class: the gain concentrates in slow devices and first navigations of a session. getState() reports the current setting, which can differ from the one that applied to this navigation if a new worker activated in between; exclude navigations whose page saw a controllerchange. Core Web Vitals context is on Core Web Vitals and field measurement techniques on Measuring Performance.
Watch the server side¶
- Share of preload requests. Count requests carrying
Service-Worker-Navigation-Preload. It should match the share of navigations from controlled clients in supporting browsers. - Duplicate requests. Look for the same client requesting the same URL twice within a second, once with the header and once without. That is the signature of a handler that calls
fetch(event.request)despite preload, or that falls through on navigations: when a handler does not callrespondWith(), the spec performs the normal navigation fetch and does not reuse the preload response. - Server load after enabling. If your worker used to answer many navigations from the cache, enabling preload turns them into server requests.
In the lab¶
In Chrome DevTools, an intercepted request's Timing tab shows a ServiceWorker Preparation phase ("the browser is starting up the service worker") and Request to ServiceWorker. Stop the worker from the Application panel's Service workers pane or from chrome://serviceworker-internals before each run so every navigation starts cold, then compare runs with preload enabled and disabled under CPU throttling. See Browser DevTools.
Browser support¶
Support data as of September 2026. For live data, see MDN's NavigationPreloadManager compatibility table and FetchEvent.preloadResponse.
| Feature | Chrome / Edge | Firefox | Safari (macOS and iOS) |
|---|---|---|---|
ServiceWorkerRegistration.navigationPreload | ✅ 59 | ✅ 99 | ✅ 15.4 |
enable(), disable(), getState() | ✅ 59 | ✅ 99 | ✅ 15.4 |
setHeaderValue() | ✅ 59 | ✅ 99 | ⚠️ 15.4 |
FetchEvent.preloadResponse | ✅ 59 | ✅ 99 | ✅ 15.4 |
| Automatic preload without opting in (ServiceWorkerAutoPreload) | ⚠️ | ❌ | ❌ |
⚠️ Safari 15.4 shipped without sending the Service-Worker-Navigation-Preload header on preload requests (WebKit bug 238564, fixed in WebKit in April 2022), so servers could not recognize preload requests from that release. The preload itself worked. ⚠️ ServiceWorkerAutoPreload is an optional Chromium optimization applied at the browser's discretion, not a web-exposed API.
Edge 18 (EdgeHTML) also implemented navigation preload; every Chromium-based Edge version (79 and later) matches Chrome. Because all current engines support it, feature detection (if (self.registration.navigationPreload)) is now mostly about older installed browsers and embedded web views.
Common pitfalls¶
- Enabling preload and never reading
preloadResponse. Every navigation makes a request nobody uses, and Chromium logs "The service worker navigation preload request was cancelled before 'preloadResponse' settled." Read it, keep it alive withwaitUntil(), or disable preload. - Calling
fetch(event.request)as well. Two requests for every navigation, possibly with different results. Use(await event.preloadResponse) ?? fetch(event.request). - Falling through on navigations while preload is enabled. Not calling
respondWith()sends the normal navigation request in addition to the preload. If you enable preload, respond to navigations. - Calling
enable()ininstall. Rejects withInvalidStateErroron first installation because there is no active worker yet. Useactivate. - Assuming a new worker version resets the setting. The enabled flag and header value live on the registration. Call
disable()in versions that do not use preload. - Treating the preload's
ok === falseas failure. Redirects arrive asopaqueredirectand must be returned unchanged. - Varying the response without
Vary: Service-Worker-Navigation-Preload. Fragments leak into CDN and HTTP caches and get served to hard reloads and new visitors. - Expecting preload for subresources or
POSTnavigations. It only applies toGETnavigations;preloadResponseresolves toundefinedotherwise. - Using the header for security decisions. Any client can send it.
- Enabling it on an app-shell site. The shell comes from the cache; preload only adds server load.
- An empty
fetchlistener "to be safe". Chromium ignores navigation preload for no-op handlers, and the handler itself only adds cost (see Handling Fetch Events).
Debugging¶
- Check the state from the page's DevTools console:
(await navigator.serviceWorker.ready).navigationPreload.getState()returns{ enabled, headerValue }. - Confirm the header reaches the server. Log
Service-Worker-Navigation-Preloadin your access logs, or inspect the navigation request's headers in the Network panel. Remember Safari 15.4's missing-header bug if you still see that version. - Watch the console for the cancellation warning. It names the fact that the preload was abandoned, not the line responsible; search your handlers for code paths that return before awaiting
preloadResponse(cache hits, early returns, routes handled by strategies that never touch the network). - Test cold starts. A warm worker hides the benefit. Use Stop in
chrome://serviceworker-internalsor in the Service workers pane, or wait out the idle timeout, before each measurement. - Test the fallback path. Toggle Offline in DevTools and navigate:
preloadResponseshould reject and your handler should serve the cached page or offline page.
Further reading¶
On this site
- Handling Fetch Events:
respondWith()rules, redirects and the full router - Service Worker Lifecycle: start-up, idle termination and the
activateevent - Static Routing API: skipping the worker, and racing network and handler
- Streaming Responses: composing shells and fragments
- Caching Strategies: network first, stale-while-revalidate and app shell
- App Shell Model: the architecture where preload does not help
- Loading Performance: where worker start-up fits in the loading timeline
- Workbox Fundamentals: strategies and routes that use preload automatically
External references
- Service Workers specification: NavigationPreloadManager and Handle Fetch
- web.dev: Speed up service worker with navigation preloads
- MDN: NavigationPreloadManager
- MDN: FetchEvent.preloadResponse
- Chrome for Developers: workbox-navigation-preload
- WebKit: New WebKit Features in Safari 15.4
- WebKit bug 238564: Service-Worker-Navigation-Preload header not sent
- Chrome Platform Status: optional ServiceWorkerAutoPreload optimization
- Workbox issue 2178: the preloadResponse cancellation warning