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.
Rapid Diagnosis
- Compare controlled and uncontrolled navigations in RUM. Navigation Timing's
workerStartis 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.
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.
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.
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.
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.
Related
- Debugging service worker cache misses in production — when cached responses are not used.
- Precaching vs runtime caching with Workbox — keeping the worker lean.
- Why lab and field LCP disagree — worker boot is a classic field-only cost.