A service worker handles navigation requests network-first and falls back to a cached /offline.html. On a cold visit the page feels slower than it did before the worker existed. What causes that delay, and what does navigation preload do about it?
answer
- the worker is not always running
- something has to boot before the fetch handler
- startup sits in front of the document
- let the browser start the request in parallel
- await a promise that may be undefined
basics
~20 sWhen the worker is not already running, the browser must boot it and evaluate its script before the fetch handler can even start the network request, so startup time is added in front of the document. Navigation preload makes the browser issue that request in parallel with worker startup.
solid answer
~50 sA service worker that is idle gets terminated, so a navigation to a controlled page often has to start it up first: fetch the script from storage, evaluate it, run the event handlers, and only then does your `fetch` handler call `fetch(event.request)`. That startup is serialised in front of the document request, which is why an otherwise sensible network-first navigation handler can add tens to a couple of hundred milliseconds to a cold load. Navigation preload removes the serialisation: you call `self.registration.navigationPreload.enable()`, and from then on the browser starts the navigation request itself *while* it boots the worker. Your handler awaits `event.preloadResponse`, uses it if it is defined, and only falls back to its own `fetch` otherwise. The preload request carries the `Service-Worker-Navigation-Preload` header so the origin can respond differently if it wants. Feature-detect it, because support arrived at different times across engines.
go deeper
Know that a service worker starts up on demand rather than running all the time, and that navigation preload lets the browser fetch the page while that startup happens.
Explain the serialised sequence — boot, evaluate, dispatch fetch, then request — and write the handler correctly: enable once, await event.preloadResponse, fall back when it is undefined.
Diagnose the regression from a real waterfall, know that preload only pays for network-bound navigations, and pair it with a precached offline fallback plus a response.ok check for error statuses.
Weigh the whole bargain: a controlled origin adds worker startup to every cold navigation, so decide which routes justify interception at all and what the origin does with the preload header.
## Where the extra time comes from A service worker is not a resident process. The browser terminates it when it goes idle and starts it again when an event needs it, which is the whole reason worker code has to be stateless between events. That policy is invisible for subresource requests, because by the time the page is fetching scripts and images the worker is already running. It is very visible for the **navigation** request, which is the event that wakes the worker up. On a cold navigation the sequence is: browser decides the URL is in a controlled scope → reads and evaluates the worker script → runs its top-level code and registers handlers → dispatches `fetch` → your handler finally calls `fetch(event.request)`. Everything before the last step is dead time added in front of the document, and the document is on the critical path for absolutely everything else. A network-first navigation handler therefore *costs* something even when the network is fast, which surprises teams who added the worker purely to make things faster. ## What navigation preload changes Navigation preload lets the browser start the network request for the navigation at the same moment it starts booting the worker. By the time your handler runs, the request is already in flight — often already finished — and it is handed to you as a promise: ```js self.addEventListener('activate', (event) => { if (self.registration.navigationPreload) { event.waitUntil(self.registration.navigationPreload.enable()); } }); self.addEventListener('fetch', (event) => { if (event.request.mode !== 'navigate') return; event.respondWith((async () => { try { const preloaded = await event.preloadResponse; if (preloaded) return preloaded; return await fetch(event.request); } catch { return (await caches.match('/offline.html')) || Response.error(); } })()); }); ``` Three rules govern that code. `event.preloadResponse` is a promise that resolves to `undefined` when preload is off or the request was not a navigation, so the `if (preloaded)` branch is mandatory rather than defensive. You must consume it — if you enable preload and then ignore the promise, the browser has issued a request nobody reads, which is wasted bandwidth and, for a non-idempotent endpoint, a wasted server hit. And the enable call belongs where it runs once per worker generation rather than on every fetch. ## The server-side half Preload requests carry the header `Service-Worker-Navigation-Preload`, whose value defaults to `true` and can be set with `navigationPreload.setHeaderValue(value)`. Origins use it to serve something cheaper for a preload — commonly a shell or a partial the worker is expected to compose — though most deployments simply ignore the header and return the normal document. ## The offline fallback in this design The fallback path is the other half of the recipe. Precache `/offline.html` so it is guaranteed present, and return it from the `catch` when both the preload and the direct fetch reject. Note the shape of the error handling: a rejected `fetch` means the network failed, but an HTTP error status resolves normally, so a 500 will be passed straight through to the user unless you also check `response.ok` and decide whether an error page is friendlier than the origin's. ## When to reach for it Navigation preload only pays when the worker is genuinely cold and the navigation is network-bound — that is, network-first or stale-revalidating documents. If your document is served cache-first from a precached shell you were never waiting on the network to begin with. Feature-detect before enabling, because the API landed in different engines years apart.
- Why must the handler check whether event.preloadResponse resolved to a value?The promise resolves to `undefined` whenever preload did not produce a response — the feature is disabled, the browser does not support it, or the request was not a navigation. Returning that from `respondWith` would give the page nothing, so the handler must fall through to its own `fetch` when the value is falsy.
- What is the cost of enabling navigation preload and then never awaiting the promise?The browser still issues the navigation request. If your handler answers from cache and drops the preload, you have paid for a full document fetch that nobody reads — bandwidth on the user's connection and load on the origin. Enable it only in workers whose navigation handler actually consumes it.
- Does navigation preload help a service worker that serves its documents cache-first from a precached shell?Very little. The point of preload is to overlap worker startup with a network request you were going to make anyway. A cache-first shell never waits on the network for the document, so the only thing preload adds there is a request you then discard.
saying these in an interview costs you the question
- Assumes the service worker is always running and adds no startup cost
- Returns event.preloadResponse without checking it is defined
- Enables preload but answers from cache and ignores the promise
- Calls navigationPreload.enable() inside the fetch handler
- Thinks preload caches the response for later use