How to Avoid the Service Worker Startup Penalty

This guide addresses a performance regression that service workers can introduce, within Service Worker Caching Strategies and Advanced Caching Strategies & CDN Architecture. A service worker is supposed to make repeat visits faster. But any request it intercepts must wait for the worker to be running, and browsers stop idle workers within seconds to tens of seconds. The next intercepted request then pays the boot cost: fetching (or reading from cache) the worker script, compiling it, running its top-level code, and dispatching the fetch event.

On a fast desktop that is tens of milliseconds. On mid-tier and low-end Android devices it is often 100–400ms, sometimes more after the device has been idle — added directly to navigation TTFB, and therefore to LCP, whenever the worker's strategy needs the network anyway. Sites that added a service worker "for performance" sometimes find field LCP got worse for returning visitors.

What happens before an intercepted request can proceed The steps a browser performs before a stopped service worker can handle a fetch event. What happens before an intercepted request can proceed Start worker thread process and thread setup — tens of ms Load and compile sw.js scales with script size and imports Run top-level code precache manifests, library setup, routes Dispatch fetch event your handler finally runs — then the network or cache

Rapid Diagnosis

  • Compare controlled and uncontrolled navigations in RUM. Navigation Timing's workerStart is non-zero for controlled navigations; compare TTFB p75 for the two groups on the same templates.
  • Trace a cold worker. Stop the worker in DevTools, then navigate with CPU throttling; look for "ServiceWorker startup" and script evaluation before the navigation request.
  • Check the worker's size. Large bundles (Workbox plus app code plus big precache manifests) take longer to compile and run.
  • Check what the worker intercepts. A fetch handler that intercepts every request, including ones it just forwards to the network, pays boot cost for nothing.

Root Cause Analysis

1. Intercepting requests that gain nothing. A pass-through fetch handler (event.respondWith(fetch(event.request))) adds boot latency with no benefit.

2. Network-first navigations without preload. The handler waits for boot, then starts the request that could have started immediately.

3. Heavy worker scripts. Importing large libraries or building big route tables at top level lengthens every cold start.

4. Slow storage. Reading the worker script and caches from slow storage on low-end devices adds to startup.

Navigation TTFB p75, returning visitors on Android Bar chart of TTFB for returning visitors without a service worker, with a heavy pass-through worker, and with an optimised worker. Navigation TTFB p75, returning visitors on Android No service worker 420ms Heavy worker, network-first 690ms Slim worker + preload 425ms Cache-first shell (optimised) 160ms

Step-by-Step Resolution

1. Remove pass-through interception

If the worker does not cache a request type, do not call respondWith for it — let the request go to the network directly. Better still, avoid registering a fetch handler for request types you never handle.

javascript
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (url.origin !== location.origin || url.pathname.startsWith('/api/')) return;   // not handled: no respondWith
  // ...handle only what the worker actually caches
});
// trade-off: returning without respondWith still requires the worker to boot to
// make that decision. Only the Static Routing API or a narrower scope avoids
// booting entirely.

2. Enable navigation preload for network-first navigations

As covered in enabling navigation preload in service workers, this overlaps boot with the network request.

3. Slim the worker script

Import only the Workbox modules you use, keep the precache manifest small, and avoid top-level work. Measure the worker's size and evaluation time like any other bundle.

4. Use the Static Routing API where supported

Chromium's Static Routing API lets the worker declare, at install time, which requests should go straight to the network or cache without starting the worker at all.

javascript
self.addEventListener('install', (event) => {
  if (event.addRoutes) {
    event.addRoutes([
      { condition: { urlPattern: '/api/*' }, source: 'network' },
      { condition: { urlPattern: '/assets/*' }, source: 'cache' },
    ]);
  }
});
// trade-off: the API is not yet available in all browsers; keep equivalent
// logic in the fetch handler as a fallback.

Expected outcome: requests the worker does not need to see never wait for it.

Should the worker intercept this request? Decision sequence for whether a service worker should handle a given request type. Should the worker intercept this request? Does the worker serve it from cache sometimes? Intercept (with preload for navigations) yes no Does it need offline fallback? Intercept with network-first + timeout yes no Pure pass-through to network? Do not intercept — static route or no respondWith yes no Leave it to the browser

Verification

In RUM, compare TTFB p75 for controlled versus uncontrolled navigations again: after the fixes, controlled navigations should be equal or faster. In lab traces with a stopped worker, check that navigation requests start immediately (preload) and that worker evaluation is short. Track the worker script's size in CI like other bundles.

Worked Example: A Worker That Slowed the Site

An e-commerce site added a service worker that precached 2MB of assets and handled every request network-first "for offline support". Returning visitors' LCP p75 on Android worsened by 280ms. Investigation showed a 310KB worker bundle (Workbox plus an analytics SDK imported by mistake), pass-through handling of API calls, and no navigation preload. The team removed the SDK, stopped intercepting API and third-party requests, cut the precache to the shell, and enabled navigation preload. Returning-visitor LCP p75 ended 120ms better than before the worker existed, thanks to the cached shell assets.

Measuring Worker Cost Continuously

Add two fields to RUM for controlled navigations: fetchStart - workerStart (worker boot and dispatch) and whether the response came from the worker's cache (transferSize === 0 with the navigation served by the worker, or a custom header your worker adds). Chart boot cost p75 by device class and alert if it grows — it rises quietly when someone adds an import to the worker. Treat the worker as a performance-critical bundle with its own budget.

Common Mistakes

  • Assuming service workers always make sites faster. They can, but only when they avoid network round trips; otherwise they add boot cost.
  • Registering a worker for analytics or push only, with a fetch handler. If you do not need fetch interception, omit the handler entirely.
  • Precaching everything. Increases install cost and storage pressure without helping navigations.
  • Ignoring third-party requests. Intercepting cross-origin requests adds boot cost and opaque response problems.

Edge Cases

Scope. Registering the worker with a narrower scope (for example /app/) leaves the rest of the site uncontrolled and free of boot cost.

Workers without fetch handlers. Browsers skip starting the worker for navigations when there is no fetch handler, so push-only workers do not slow navigations.

Background sync and push. These start the worker independently of navigations and do not affect page loads.

Prerendered pages. Prerendering with a service worker controlling the page runs the worker during prerender, hiding its cost from the eventual navigation.

FAQ

How long do browsers keep a worker alive?

Implementation-defined and short — typically seconds to tens of seconds of idleness, with hard caps on long-running events. You cannot rely on it being warm.

Is cache-first always faster despite boot time?

Usually, because it avoids the network entirely; boot time is far less than a network round trip on mobile. The penalty matters for strategies that still go to the network.

Does the worker script itself get cached?

Yes, by the browser's service worker script cache, with update checks on navigation. Startup still requires reading and compiling it; code caching reduces but does not eliminate the cost.

Should I unregister a worker that does not help?

If it provides no caching, offline or push value, yes. Ship a self-unregistering worker so existing clients remove it.

Does the startup penalty affect INP?

Not directly — INP measures interactions after load. It affects TTFB and LCP for navigations, and the latency of any intercepted fetch that happens when the worker is stopped.

How do I measure workerStart in RUM?

Read performance.getEntriesByType('navigation')[0].workerStart; it is zero when no worker controlled the navigation. The difference between fetchStart and workerStart approximates worker overhead.

/html>