Skip to main content

What is service worker?

Service worker - a JavaScript script that runs on its own background thread, separate from your web page, intercepting every network request your app makes.

Theory

TL;DR

  • A service worker sits between your page and the network, like a proxy you control with code
  • It runs on its own thread, has no DOM access, and persists after the page closes
  • The browser fires three events in order: install (cache assets), activate (clean old caches), fetch (intercept requests)
  • Use it when you need offline support, faster repeat visits, push notifications, or background sync
  • Not needed for simple static sites or server-rendered apps without offline requirements

Quick example

javascript
// 1. Register in your main app file if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js') .then(reg => console.log('SW registered')) .catch(err => console.log('SW failed:', err)); } // 2. Inside sw.js - return cached response or go to network self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request) .then(response => response || fetch(event.request)) // Cached version first, network as fallback ); });

Two files: the registration code runs on your page, the worker logic runs in sw.js on its own thread.

Separate thread, no DOM

Service workers run off the main thread. They never block UI rendering, no matter what they do. But that also means no document, no window, no localStorage. Try to access document inside a service worker and you get a ReferenceError.

To pass data between the worker and the page, use postMessage(). The worker sends a message, the page listens and updates the DOM. That is the only communication channel.

Lifecycle

Before a service worker handles any requests, it goes through three phases:

  1. install - fires once when the browser first downloads the script. Pre-cache assets here using event.waitUntil(). If caching fails, the install fails and the worker does not activate.
  2. activate - fires after install, once the old service worker is gone. Clean up stale caches here so users do not get stuck with outdated files.
  3. fetch - fires on every network request from the page. This is where you decide: serve from cache, fetch from network, or a mix of both.

After activation, the service worker stays alive in the background even when the page is closed. That is what makes push notifications and background sync possible.

When to use

  • Progressive Web Apps (PWAs): need offline mode or an installable app experience
  • Performance: cache static assets (CSS, JS, images) so repeat visits skip the network entirely
  • Unreliable networks: mobile users who drop connectivity mid-session
  • Background sync: queue form submissions while offline, send them when the connection returns
  • Push notifications: show alerts even when the browser tab is closed

Skip service workers for simple marketing sites, fully server-rendered apps, or anything that does not need offline support.

Caching strategies

Two patterns cover most real apps.

Cache-first for static assets: check cache, return immediately, fall back to network if not found. Fast. Good for CSS, JS, and fonts that rarely change.

Network-first for API calls: hit the network first, store the response in cache, fall back to cache if offline. Keeps data fresh. Good for user profiles, feeds, and any dynamic content.

You apply these per request type, not globally. A real sw.js runs both strategies inside the same fetch handler.

Common mistakes

1. Not versioning caches

The browser caches the service worker script itself. Update /sw.js and users keep the old version until they close every open tab and revisit. The fix is simple: bump the cache version on every deploy.

javascript
// Wrong: cache name never changes, users are stuck const CACHE_NAME = 'app-cache'; // Right: increment version with each release const CACHE_NAME = 'app-v2'; self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(names => Promise.all(names.map(name => name !== CACHE_NAME && caches.delete(name) )) ) ); });

2. Caching API responses without thinking about stale data

Cache a user profile and it sits there until you explicitly clear it. The server updates the profile, the user still sees old data. Use network-first for anything that changes server-side. Cache-first belongs only on static assets.

3. Trying to access the DOM

javascript
// Wrong: ReferenceError inside a service worker self.addEventListener('fetch', event => { document.body.innerHTML = 'Offline'; // ReferenceError }); // Right: send a message to the page instead self.clients.matchAll().then(clients => { clients.forEach(client => client.postMessage({ status: 'offline' })); });

4. Ignoring the HTTPS requirement

Service workers only run on HTTPS. Locally, localhost and 127.0.0.1 work without it. Deploy to plain HTTP in production and the service worker does not register at all. Always confirm your production environment uses HTTPS before debugging anything else.

5. Expecting instant updates

New service worker code does not activate while the user has any tab open. If you need immediate activation (risky), call self.skipWaiting() in install and clients.claim() in activate. Do this only if the new version is fully compatible with any data already in the cache.

Real-world usage

  • Workbox (Google): wraps caching strategies into a clean API; used by Gatsby, Create React App, and Firebase Hosting
  • Next.js: the next-pwa plugin adds service worker support for static exports
  • Notion, Figma, Google Docs: cache documents locally and sync changes when connection returns
  • Twitter / X: queues posts while offline, sends them when connection returns
  • Shopify PWAs: cache product images and checkout pages for faster load times

I have seen teams skip the cache version bump once and spend an afternoon figuring out why half their users saw a broken layout. Automating the version increment in your CI/CD pipeline saves that headache entirely.

Follow-up questions

Q: What is the difference between a service worker and a web worker?
A: Web workers run on a separate thread but are tied to a single page and stop when it closes. Service workers persist across page closes, handle requests from multiple tabs, and intercept network traffic. Web workers are for CPU-heavy computation; service workers are for caching and offline support.

Q: Can a service worker access localStorage?
A: No. localStorage is main-thread only. Use IndexedDB instead. It works in service workers, handles larger data (50MB+ vs 5-10MB), and is designed for async access.

Q: How do you update a service worker without breaking the user experience?
A: Version your cache names, clean up old caches in activate, and show a "new version available" banner that reloads on user click. Use skipWaiting() only if you are confident the new version is backward-compatible with existing cached data.

Q: What happens if a service worker throws an error?
A: The browser catches it, the worker stops, and requests fall through to the network. The registration stays intact. Check DevTools > Application > Service Workers for error logs.

Q: (Senior) How would you handle offline edits that conflict with server changes after a user reconnects hours later?
A: Store every offline change in IndexedDB with a timestamp and change ID. On reconnect, diff local changes against the server version and apply a conflict strategy. Last-write-wins is simple but destructive. Field-level merging is more complex but safer. Libraries like Automerge and Yjs use CRDTs (Conflict-free Replicated Data Types) to resolve conflicts automatically. The hard part is showing the merge result in the UI without confusing the user.

Examples

Basic: register and serve from cache

javascript
// In your main app file if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js'); } // sw.js const CACHE = 'app-v1'; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE).then(cache => cache.addAll(['/index.html', '/styles.css', '/app.js']) ) ); }); self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(cached => cached || fetch(event.request)) ); });

Static assets go into cache on install. Every request checks the cache first, falls back to the network if not found.

Intermediate: cache-first for assets, network-first for API

javascript
const CACHE_NAME = 'app-v2'; self.addEventListener('fetch', event => { const { request } = event; // Cache-first: static files load instantly on repeat visits if (request.url.includes('/static/')) { event.respondWith( caches.match(request).then(cached => cached || fetch(request)) ); return; } // Network-first: API responses stay fresh, cache covers offline if (request.url.includes('/api/')) { event.respondWith( fetch(request) .then(response => { const copy = response.clone(); caches.open(CACHE_NAME).then(cache => cache.put(request, copy)); return response; }) .catch(() => caches.match(request)) ); } });

One fetch handler, two strategies. Static files never wait for the network. API calls always try for fresh data but survive being offline.

Advanced: stale-while-revalidate

javascript
// Return cached response immediately, update cache in background self.addEventListener('fetch', event => { if (event.request.method !== 'GET') return; event.respondWith( caches.open(CACHE_NAME).then(cache => { return cache.match(event.request).then(cached => { const networkFetch = fetch(event.request).then(response => { if (response.status === 200) { cache.put(event.request, response.clone()); } return response; }); // Cached content loads instantly; fresh version saves to cache in background return cached || networkFetch; }); }) ); });

The user sees content immediately. In the background, the worker fetches a fresh version and stores it. The next visit gets the updated content. Good balance between speed and freshness for pages that change occasionally.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?