Web Workers vs Service Workers

TL;DR / Web Workers provide background threads for CPU-intensive computation, while Service Workers act as programmable network proxies that intercept requests and enable offline functionality.

How It Works

 ┌──────────────┐            ┌────────────────┐
 │ Main Thread  │───────────→│   Web Worker   │
 └──────────────┘            └────────────────┘
         │                   CPU-heavy tasks
         │                   1:1 with page
         └┐                  No DOM access
          │
          ↓
 ┌────────────────┐          ┌────────────────┐
 │ Service Worker │─────────→│    Network     │
 └────────────────┘          └────────────────┘
 Network proxy
 Shared across tabs
 Lifecycle-driven

Edit diagram

Both Web Workers and Service Workers run JavaScript on threads separate from the main thread, but they serve fundamentally different purposes, have different lifecycles, and operate under different constraints. Understanding when to use each — and when to combine them — is essential for building performant web applications.

Web Workers: Dedicated Computation

A Web Worker (specifically, a Dedicated Worker) is a thread spawned by a specific page to perform CPU-intensive work without blocking the main thread's rendering and event handling. You create one with new Worker('worker.js') and communicate via postMessage() and onmessage handlers. Each message is serialized using the structured clone algorithm — objects are deep-copied, not shared (unless using SharedArrayBuffer or transferable objects).

Web Workers have no access to the DOM, window, document, or any UI API. They can use fetch, IndexedDB, WebSocket, crypto, and most computation-focused APIs. They live and die with the page that created them — when the tab closes, the worker is terminated. Each page that creates a worker gets its own instance; there is no sharing between tabs.

Typical use cases include image processing, data transformation, CSV parsing, encryption, physics simulations, and any operation that would cause jank if run on the main thread. The overhead of serializing messages means Web Workers are not worth it for trivial operations — the cost of cloning data can exceed the cost of just computing on the main thread. They shine when processing takes more than approximately 16ms (one frame).

Shared Workers

A Shared Worker is a variant that multiple pages (tabs, iframes) from the same origin can connect to. You create one with new SharedWorker('worker.js'), and communication happens through a MessagePort. All connected pages share the same worker instance, useful for synchronizing data across tabs or maintaining a single WebSocket connection.

Shared Workers have limited browser support and add complexity around port management. For many use cases, BroadcastChannel or SharedArrayBuffer provides simpler cross-tab coordination.

Service Workers: Network Proxy

A Service Worker is a fundamentally different beast. It acts as a programmable proxy between the browser and the network. When registered, it intercepts every network request made by pages under its scope (the directory path of the service worker script and below). The fetch event handler decides how to respond: serve from cache, forward to the network, return a synthetic response, or any combination.

The lifecycle of a Service Worker is decoupled from any page. It installs once, activates, and persists across page navigations and tab closures. The browser starts it when there is work to do (fetch event, push notification, background sync) and terminates it when idle. This means Service Workers cannot maintain in-memory state — anything that must persist must be stored in IndexedDB or the Cache API.

Service Workers are the foundation for Progressive Web App features: offline support (serving cached assets when the network is unavailable), background sync (deferring network requests until connectivity returns), and push notifications (waking the worker in response to a server-sent push event, even when no tab is open).

Lifecycle Differences

A Web Worker's lifecycle is simple: new Worker() starts it, worker.terminate() or page close ends it. A Service Worker's lifecycle is complex and event-driven: navigator.serviceWorker.register() starts the install phase, the install event fires once for caching initial assets, the activate event fires when the worker takes control, and fetch/push/sync events fire during operation. Updating a Service Worker involves installing a new version alongside the existing one; the new version waits until all tabs using the old version are closed (unless skipWaiting() is called).

Security Context

Both require HTTPS (or localhost for development). Service Workers have an additional constraint: they can only be registered from a secure context and their scope is limited to the path of the service worker script. A worker at /app/sw.js can only intercept requests for /app/* and below, not for the entire origin (unless the server sends a Service-Worker-Allowed header to expand the scope).

Communication Patterns

Web Workers use direct postMessage to and from the creating page. Service Workers communicate with pages through Client.postMessage() and the message event on navigator.serviceWorker. A Service Worker can broadcast to all controlled clients via self.clients.matchAll(), enabling cross-tab messaging without BroadcastChannel.

Gotchas

  • Service Workers cannot access the DOM — like Web Workers, they have no document or window. But unlike Web Workers, they also have no localStorage (only IndexedDB and Cache).
  • Service Worker updates are not instant — a new version installs in the background but does not activate until all tabs running the old version close, unless you call self.skipWaiting() and clients.claim(). This catches many developers off guard during debugging.
  • Web Worker message serialization has overhead — transferring large data structures via postMessage involves structured cloning, which can be expensive. Use Transferable objects or SharedArrayBuffer for large buffers.
  • Service Workers terminate when idle — you cannot store state in global variables and expect it to persist. Every activation starts fresh. Use IndexedDB for persistent state.
  • Debugging is non-obvious — Web Workers appear in the browser's Sources panel under a "Workers" section. Service Workers have their own DevTools panel (chrome://serviceworker-internals/ or Application tab). Breakpoints, console logs, and network requests are scoped to the worker context.