Skip to content

Navigation preload as a performance fix: starting the network request while the service worker boots

In one line: Without navigation preload, a service worker’s boot-up time sits in front of a navigation’s network request; per web.dev, enabling preload lets the browser start the network request while the service worker is booting up instead, so the boot-up time no longer blocks the network request.

For a page controlled by a service worker that has a fetch event handler, the browser must dispatch that event to the worker and wait for the response — which means booting the worker first if it is not already running. Per web.dev, that boot-up time is device- and condition-dependent: commonly around 50ms on desktop, more like 250ms on mobile, and it can exceed 500ms on a slow or CPU-throttled device. Without navigation preload, the network request for the navigation does not start until after the worker has booted and begun executing the fetch handler — so boot time is added directly onto the time it takes to get a response.

Time to First Byte includes the time the browser spends checking any active service worker for a matching response before the response arrives. When that check requires a cold boot, the boot delay becomes part of TTFB. Per web.dev, navigation preload starts the network request while the service worker is booting up; the startup delay is still there, but it doesn’t block the network request.

addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
if ('navigationPreload' in self.registration) {
await self.registration.navigationPreload.enable();
} else {
// Unsupported: nothing to enable. The fetch handler below still
// works, it just waits for the worker to finish booting before the
// navigation request starts, same as it would without this file.
return;
}
})(),
);
});
addEventListener('fetch', (event) => {
event.respondWith(
(async () => {
const preload = await event.preloadResponse;
if (preload) return preload;
return fetch(event.request);
})(),
);
});

'navigationPreload' in self.registration is the feature-detection check; event.preloadResponse resolves to undefined when preload did not run for that request (for example, non-GET or non-navigation requests), so the handler must always fall back to a normal fetch(). MDN’s own FetchEvent.preloadResponse example keeps a started-but-unused preload request alive with event.waitUntil(preloadResponsePromise.catch(() => undefined)), so it isn’t cancelled if the handler ends up serving from cache instead.

NavigationPreloadManager requires a secure context (HTTPS) and is Baseline widely available, per MDN, since April 2022.

Navigation preload is not universally beneficial. Per web.dev, if a page is served straight from the Cache Storage API, the service worker’s boot-up time isn’t a significant problem, because the cached response is already available without waiting on the network — so the delay preload exists to hide is not the bottleneck for that response in the first place.

  • Measure whether your navigation responses come from the cache or the network before adopting preload — a fully precached app shell gets less benefit from it.
  • Enable it in activate, guarded by if ('navigationPreload' in self.registration).
  • Always await event.preloadResponse and fall back to fetch() when it resolves to undefined.
  • Keep a started-but-unused preload request alive with event.waitUntil(), per MDN’s FetchEvent.preloadResponse example, so it isn’t cancelled if you end up serving from cache instead.
  • Set Vary: Service-Worker-Navigation-Preload on the server if the preload and normal responses can differ, so caching doesn’t mix them up.