Skip to content

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 the activate event, then use await event.preloadResponse instead of fetch(event.request) for navigations.
  • The preload request is the navigation request plus a Service-Worker-Navigation-Preload header (default value true, changeable with setHeaderValue()); servers that vary their response on it must send Vary: Service-Worker-Navigation-Preload.
  • Preload only applies to GET navigations (pages and iframes), and its state lives on the registration, so it persists across worker updates until something calls disable().
  • If you enable it and then answer from the cache without touching preloadResponse, you waste a request and Chromium warns; if you also call fetch(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:

  1. The request is a navigation request (a document or iframe navigation).
  2. Its method is GET.
  3. The registration's active worker handles fetch events (its set of event types to handle contains fetch).
  4. That worker's fetch listeners 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."
  5. The registration's navigation preload enabled flag is set.
  6. No static routing rule already decided the request's fate. A network or cache rule answers the request before the preload step runs, and the race-network-and-fetch-handler source races its own network request and resolves preloadResponse with undefined.

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 resolves preloadResponse with a response of type opaqueredirect (status 0, ok === false), which you pass straight to respondWith(); the browser follows it and the target URL gets its own fetch event 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), preloadResponse rejects with a TypeError. 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.

NavigationPreloadManager IDL (Service Workers spec)
[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:

sw.js - enabling navigation preload
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 activating worker to become activated. A navigation that arrives while your activate handler is still running is judged by the flag's old value, so it gets no preload even though the fetch event itself waits for activation. Navigations that start after enable() has resolved get one. Keep activate short and call enable() first, before slower work such as cache cleanup.
  • enable() in install fails only sometimes. On an update, the old worker is still the registration's active worker while the new one installs, so enable() succeeds; on a first install it rejects. Code that enables preload in install therefore works whenever you test an update and fails for every new visitor. If that rejected promise is passed to event.waitUntil(), the whole installation fails and the worker is discarded. Use activate.

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.
sw.js - keep the header in sync with the cached shell
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:

sw.js - a worker version that no longer uses preload
self.addEventListener("activate", (event) => {
  event.waitUntil(self.registration.navigationPreload?.disable());
});

From a page, the same API is handy for diagnostics and for experiments:

app.js - inspect navigation preload from the page
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

sw.js - network-first navigations with preload
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:

sw.js - cache first, keeping the preload alive
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.ok as a failure. A redirected preload arrives as opaqueredirect with ok === false and 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 ok responses of type basic.
  • 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

sw.js
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:

Preload request (abridged)
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:

Response to a preload request
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).

server.js
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);
nginx.conf (excerpt)
# 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.

sw.js - stream a cached shell around a preloaded fragment
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.

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.

sw.js
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();
});
sw.js
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.

sw.js - assign a preload cohort per worker version
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.
rum.js - report navigation timing with the cohort
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 call respondWith(), 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

  1. 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 with waitUntil(), or disable preload.
  2. Calling fetch(event.request) as well. Two requests for every navigation, possibly with different results. Use (await event.preloadResponse) ?? fetch(event.request).
  3. 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.
  4. Calling enable() in install. Rejects with InvalidStateError on first installation because there is no active worker yet. Use activate.
  5. 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.
  6. Treating the preload's ok === false as failure. Redirects arrive as opaqueredirect and must be returned unchanged.
  7. 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.
  8. Expecting preload for subresources or POST navigations. It only applies to GET navigations; preloadResponse resolves to undefined otherwise.
  9. Using the header for security decisions. Any client can send it.
  10. Enabling it on an app-shell site. The shell comes from the cache; preload only adds server load.
  11. An empty fetch listener "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-Preload in 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-internals or in the Service workers pane, or wait out the idle timeout, before each measurement.
  • Test the fallback path. Toggle Offline in DevTools and navigate: preloadResponse should reject and your handler should serve the cached page or offline page.

Further reading

On this site

External references