Skip to content

Concept in Performance

Navigation preload and service worker boot time

Published

A navigation to a page controlled by a service worker with a fetch handler cannot start its network request until the worker has started and run the handler, so a worker that is not already running adds its boot time to the response. Navigation preload tells the browser to send the navigation request at the same time as it boots the worker, and hands the response to the handler as event.preloadResponse, which removes the boot from the critical path for responses that come from the network.

Service workers are stopped when idle and started on demand. The web.dev measurements put the boot at around 50 ms on desktop, closer to 250 ms on a mid-range phone, and over 500 ms on a slow or throttled device (Speed up service worker with navigation preloads, web.dev). Without preload that time is serial with the request; with preload the browser issues the request immediately, adds a Service-Worker-Navigation-Preload: true header so the server can tell the two apart, and resolves event.preloadResponse with the response once the handler asks for it. The feature is enabled per registration with registration.navigationPreload.enable(), usually in activate, and the header value can be changed with setHeaderValue() so a server can return a partial response (a content fragment instead of a full page) for preload requests. Support is Chrome 59, Firefox 99, and Safari 15.4 (BCD api.NavigationPreloadManager), and the method-level reference is the service worker entry.

NavigationPreloadManager is in Chrome 59, Firefox 99, and Safari 15.4, in every case alongside FetchEvent.preloadResponse (BCD api.NavigationPreloadManager, api.FetchEvent.preloadResponse), and only on secure origins. In an engine without it registration.navigationPreload is undefined and event.preloadResponse resolves undefined, so the handler’s fallback path runs.

Time to First Byte, as measured by PerformanceNavigationTiming.responseStart, includes workerStart to fetchStart, the interval in which the browser starts the worker. On a repeat visit served from Cache Storage the interval still exists but the response needs no network, so TTFB is the boot plus a cache read. On a visit served from the network, preload overlaps the boot with the request’s round trip, so TTFB drops by roughly the boot time.

  • Cache-first navigations. If the handler answers from Cache Storage, the preloaded network response is unused and the request wasted bandwidth; the fix is to not enable preload, or to enable it only for scopes that go to the network.
  • Workers that are already running. A worker kept alive by a recent event has nothing to boot, and preload changes nothing.
  • Vary and caching. The preload request differs from a normal navigation only by its header, so a CDN that caches by URL alone can serve a preload-shaped partial response to a normal navigation. The server answers with Vary: Service-Worker-Navigation-Preload when the two responses differ.

An unused preloadResponse is cancelled when the handler returns another response unless the handler keeps it alive with event.waitUntil(); letting it cancel is the right default.

The first two examples are service worker code; the third measures the effect from the page.

Enabling preload and using the preloaded response

Section titled “Enabling preload and using the preloaded response”

Enable in activate, guarded by a feature check; in fetch, prefer the preloaded response and fall back to fetch() when it resolves undefined, which it does for non-navigation requests and in engines without the feature.

self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
if ('navigationPreload' in self.registration) {
await self.registration.navigationPreload.enable();
}
})());
});
self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate') return;
event.respondWith((async () => {
const preloaded = await event.preloadResponse;
if (preloaded) return preloaded;
return fetch(event.request); // no preload here: ordinary network request
})());
});

Returning early for non-navigation requests keeps the handler from intercepting subresources it does not need to touch, which also shortens their path.

Serving a content fragment to preload requests

Section titled “Serving a content fragment to preload requests”

With setHeaderValue('fragment') the server can return only the part the shell needs and the handler stitches it into the cached shell, which is faster than a full page and cheaper than two requests.

// service worker, in activate
await self.registration.navigationPreload.setHeaderValue('fragment');
// service worker, in fetch for navigations
const shell = await caches.match('/shell.html');
const preloaded = await event.preloadResponse;
if (shell && preloaded) {
const fragment = await preloaded.text();
return new Response(renderShell(await shell.text(), fragment), { headers: { 'content-type': 'text/html' } });
}

The server reads Service-Worker-Navigation-Preload: fragment and sets Vary: Service-Worker-Navigation-Preload on the response so caches keep the fragment and the full page apart.

workerStart is non-zero on navigations handled by a service worker; the difference to fetchStart is the interval preload overlaps.

const [nav] = performance.getEntriesByType('navigation');
if (nav && nav.workerStart > 0) {
console.log('worker boot', (nav.fetchStart - nav.workerStart).toFixed(1), 'ms');
} else {
console.log('no service worker on this navigation'); // nothing for preload to hide
}

Comparing this figure with responseStart - requestStart shows whether the boot or the server dominates TTFB on a given device, which is the number that decides whether preload is worth the wasted request on cache hits.

Specifications

SpecificationStatus
None.