When service workers slow down navigations

Service workers can delay page loads. When a user navigates to a page controlled by a service worker, the browser must boot the worker before it can dispatch the fetch event and produce a response. That boot time is typically around 50ms on desktop, closer to 250ms on mobile, and can exceed 500ms on constrained devices. The delay only appears occasionally — a service worker stays alive between events, so the cost is paid on cold starts, such as opening a fresh tab or arriving from another origin.

If the service worker answers from cache, the startup cost is usually worth it. But when the response has to come from the network, the worker's boot time is pure overhead sitting in front of the request.

Navigation preload solves this by starting the network request for a navigation at the same time the service worker boots. The startup delay no longer blocks the request, so the page content arrives sooner. The feature is supported in Chrome 59+, Edge 18+, Firefox 99+, and Safari 15.4+.

Enabling navigation preload

Enable the feature in the service worker's activate event, since your fetch handler needs to know it's available:

addEventListener('activate', event => {
  event.waitUntil(async function() {
    // Feature-detect
    if (self.registration.navigationPreload) {
      // Enable navigation preloads!
      await self.registration.navigationPreload.enable();
    }
  }());
});

You can call navigationPreload.enable() (or navigationPreload.disable()) at any point, but the activate event is the natural place because the fetch handler will rely on it.

Consuming the preloaded response

Enabling the preload doesn't change your fetch handling by itself. You still have to read the response from event.preloadResponse:

addEventListener('fetch', event => {
  event.respondWith(async function() {
    // Respond from the cache if we can
    const cachedResponse = await caches.match(event.request);
    if (cachedResponse) return cachedResponse;

    // Else, use the preloaded response, if it's there
    const response = await event.preloadResponse;
    if (response) return response;

    // Else try the network.
    return fetch(event.request);
  }());
});

event.preloadResponse is a promise. It resolves with a response when three conditions are met: navigation preload is enabled, the request is a GET, and the request is a navigation request (including iframe loads). In all other cases the promise resolves with undefined.

Custom responses with the preload header

For pages that need network data, a common pattern is to merge cached content with a network stream. The cached shell renders immediately while the network payload streams in. The include request, however, still waits on service worker startup. Navigation preload can help here, as long as you don't want the full page — just the dynamic include.

Every preload request carries a header the server can inspect:

Service-Worker-Navigation-Preload: true

The server can return different content for preload requests than for regular navigations. If it does, add a Vary: Service-Worker-Navigation-Preload header so caches don't mix the two response types.

You can then use the preloaded response in your handler:

// Try to use the preload
const networkContent = Promise.resolve(event.preloadResponse)
  // Else do a normal fetch
  .then(r => r || fetch(includeURL))
  // A fallback if the network fails.
  .catch(() => caches.match('/article-offline.include'));

const parts = [
  caches.match('/article-top.include'),
  networkContent,
  caches.match('/article-bottom')
];

Customizing the header value

The default header value is true. You can change it to any string the server understands:

navigator.serviceWorker.ready.then(registration => {
  return registration.navigationPreload.setHeaderValue(newValue);
}).then(() => {
  console.log('Done!');
});

For example, you could set it to the ID of the latest cached post so the server only sends content newer than what the client already has.

Checking the current state

To inspect whether navigation preload is active, call getState:

navigator.serviceWorker.ready.then(registration => {
  return registration.navigationPreload.getState();
}).then(state => {
  console.log(state.enabled); // boolean
  console.log(state.headerValue); // string
});

The returned object also includes how the header will be set on future preload requests.