# Navigation preload and service worker boot time

> Why a service worker's boot time adds to Time to First Byte on navigations, how navigation preload overlaps the request with it, and when it does not help.

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.

## How it works

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](https://web.dev/blog/navigation-preload),
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](/reference/service-worker/navigation-preload/).

### Support position

`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.

### Where the time goes in TTFB

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.

### When preload does not help

- **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.

## Examples

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

### 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.

```js
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

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.

```js
// 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.

### Measuring the boot from the page

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

```js
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.

:::observed
A preloaded navigation appears in Chrome DevTools, Network panel, as the document request with
a request header `Service-Worker-Navigation-Preload: true` (or the value passed to
`setHeaderValue()`), visible under **Headers > Request Headers**, while the worker's own
`fetch(event.request)` fallback would appear as a second document request with a gear icon;
seeing only the first confirms `preloadResponse` was used. Calling
`navigationPreload.enable()` before the registration has an active worker rejects with
`InvalidStateError`, which is why the call lives in `activate` rather than `install`
([NavigationPreloadManager](https://developer.mozilla.org/en-US/docs/Web/API/NavigationPreloadManager),
developer.mozilla.org).
:::

## See also

- [Speed up service worker with navigation preloads](https://web.dev/blog/navigation-preload) (web.dev)
- [Service Workers: NavigationPreloadManager](https://w3c.github.io/ServiceWorker/#navigation-preload-manager) (w3.org)
- [Navigation preload (service worker API reference)](/reference/service-worker/navigation-preload/)
- [The app shell model](/reference/performance/app-shell/)
- [PerformanceObserver and paint timing](/reference/performance/startup-performance/)