Skip to main content

Command Palette

Search for a command to run...

Web Workers vs Shared Workers vs Service Workers: A Practical Guide to Choosing the Right One

TL;DR: Choose a dedicated Web Worker for expensive work needed by one page, a Shared Worker for live coordination among same-origin tabs, and a Service Worker for request interception and offline behavior. They can work together, but none is a permanent background process or a substitute for persistent storage.

Updated
•14 min read•View as Markdown

Reading time: ~13 minutes · Level: Intermediate JavaScript · Updated: October 2026

A page can freeze even though it uses a Service Worker. Two tabs can disagree even though each has a Web Worker. And an offline app can fail even though its Service Worker registered successfully.

The problem is treating worker as one job description. Each type has a different owner, communication model, and lifetime.

💡 The mental model: A dedicated worker is a private assistant for one caller. A Shared Worker is a coordinator for connected tabs. A Service Worker is an event-driven request handler for clients within its scope.

We will compare their communication patterns, lifecycles, and real-world use cases, then build a small example of each.

What all three have in common

Workers execute JavaScript outside the page’s main execution thread. They have their own global context, generally referenced as self, and cannot directly manipulate the page’s DOM. A worker that needs to change a button or display a result sends information to the page; the page performs the DOM update. MDN: Using Web Workers · MDN: Service Worker API

That separation is useful, but it is not free. Messages generally use structured cloning, which can cost time and memory for large payloads. Moving a small calculation to a worker might add overhead without improving the experience; moving a substantial calculation can keep the main thread available for interactions. MDN: Using Web Workers · web.dev: Web worker overview

The comparison at a glance

Question Dedicated Web Worker Shared Worker Service Worker
Primary job Compute for one caller Coordinate connected contexts Handle events and controlled requests
Start with new Worker(url) new SharedWorker(url) navigator.serviceWorker.register(url)
Typical connection Creator ↔ worker Each client ↔ its MessagePort Controlled client → fetch event
Multiple tabs share one instance? ❌ Not by default ✅ Eligible same-origin tabs can ✅ One registration can serve multiple scoped clients
Intercept page requests? ❌ ❌ ✅ For controlled clients
Direct DOM access? ❌ ❌ ❌
State survives all tabs closing? ❌ ❌ Do not rely on it ❌ Global variables are not durable
Typical use Image processing, parsing, simulation One live connection feeding several tabs Offline fallback, caching, push handling

A Service Worker does not automatically intercept every request from an origin. Whether it handles a request depends on client control and scope; its fetch handler can also leave a request untouched. That distinction matters when debugging a first install. MDN: Using Service Workers · MDN: Fetch event

1. Dedicated Web Workers: protect one page’s responsiveness

Suppose a page calculates prime numbers while a user continues clicking and scrolling. A long synchronous calculation on the main thread delays those interactions. A dedicated worker runs the calculation separately and returns the answer in a message. web.dev: Web worker overview

Example: calculate primes in a dedicated worker

Serve these files from a local development server. The page owns the UI; the worker owns the computation.

<!-- index.html -->
<input id="limit" type="number" min="2" value="2000000" />
<button id="calculate">Count primes</button>
<p id="result" role="status"></p>

<script src="./main.js"></script>
// main.js — page thread
const input = document.querySelector("#limit");
const button = document.querySelector("#calculate");
const result = document.querySelector("#result");
const worker = new Worker("./prime-worker.js");

button.addEventListener("click", () => {
  const limit = Number(input.value);

  if (!Number.isSafeInteger(limit) || limit < 2) {
    result.textContent = "Enter an integer of at least 2.";
    return;
  }

  button.disabled = true;
  result.textContent = "Calculating…";
  worker.postMessage({ limit });
});

worker.addEventListener("message", ({ data }) => {
  result.textContent = `Found ${data.count} primes up to ${data.limit}.`;
  button.disabled = false;
});

worker.addEventListener("error", (event) => {
  result.textContent = `Worker failed: ${event.message}`;
  button.disabled = false;
});
// prime-worker.js — dedicated worker
self.addEventListener("message", ({ data }) => {
  const { limit } = data;
  let count = 0;

  for (let candidate = 2; candidate <= limit; candidate++) {
    let isPrime = true;

    for (let divisor = 2; divisor * divisor <= candidate; divisor++) {
      if (candidate % divisor === 0) {
        isPrime = false;
        break;
      }
    }

    if (isPrime) count++;
  }

  self.postMessage({ limit, count });
});

The page calls worker.postMessage(). The worker receives a message event and replies with self.postMessage(). To stop an unneeded dedicated worker immediately, call worker.terminate(). MDN: Using Web Workers

When one worker handles several jobs

Raw messages are easy for one request, but what happens when several requests are in flight? Give each request an ID and return that ID with its result. Then the page can match responses to the right Promise. This is a messaging pattern, not a different worker type. MDN: Using Web Workers

// Page-side sketch: correlate concurrent requests.
let nextId = 0;
const pending = new Map();

worker.addEventListener("message", ({ data }) => {
  const request = pending.get(data.id);
  if (!request) return;

  pending.delete(data.id);
  data.error
    ? request.reject(new Error(data.error))
    : request.resolve(data.result);
});

function requestWorker(type, payload) {
  const id = ++nextId;

  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject });

    try {
      worker.postMessage({ id, type, payload });
    } catch (error) {
      pending.delete(id);
      reject(error);
    }
  });
}

The matching worker must reply with { id, result } or { id, error }. In a real wrapper, also reject pending requests on worker failure or termination, and consider timeouts so a lost reply cannot leave a Promise pending forever.

Large payloads: copy or transfer?

For ordinary messages, structured cloning produces data in the receiving context. For a large ArrayBuffer, transfer ownership when the sender no longer needs it:

// Page thread
const buffer = new ArrayBuffer(1024 * 1024);
worker.postMessage({ type: "process", buffer }, [buffer]);

// The sender no longer owns the buffer after transfer.
console.log(buffer.byteLength); // 0

This avoids copying the buffer’s underlying data, but the page cannot keep using the transferred buffer unless the worker transfers it back. SharedArrayBuffer is a different mechanism: it enables actual shared memory and brings additional security and synchronization requirements. MDN: Transferable objects · MDN: Using Web Workers—Sharing data

Use a dedicated worker when: substantial work belongs to one caller and main-thread responsiveness matters.

2. Shared Workers: coordinate several open tabs

Imagine a dashboard open in three tabs. If each tab opens its own WebSocket, you may create duplicate connections and process the same updates three times. A Shared Worker offers a place to manage one connection for the currently connected tabs and fan out its messages. That is an architectural option—not a promise of “one connection per user” across devices, browser profiles, or every possible browsing context. MDN: SharedWorker

Example: live in-memory count across tabs

Open this page in two tabs of the same origin:

<!-- counter.html -->
<button id="increment">Increment</button>
<p id="count" role="status">Connecting…</p>
<script src="./counter.js"></script>
// counter.js — runs separately in each tab
const output = document.querySelector("#count");
const button = document.querySelector("#increment");

if ("SharedWorker" in window) {
  const worker = new SharedWorker("./counter-worker.js");
  const port = worker.port;

  port.addEventListener("message", ({ data }) => {
    if (data.type === "count") {
      output.textContent = `Shared count: ${data.value}`;
    }
  });

  port.start();
  button.addEventListener("click", () => {
    port.postMessage({ type: "increment" });
  });
} else {
  output.textContent = "Shared Workers are unavailable.";
  button.disabled = true;
}
// counter-worker.js — shared by connected tabs
let count = 0;
const ports = new Set();

self.addEventListener("connect", (event) => {
  const port = event.ports[0];
  ports.add(port);

  port.addEventListener("message", ({ data }) => {
    if (data.type === "disconnect") {
      ports.delete(port);
      port.close();
      return;
    }

    if (data.type !== "increment") return;

    count++;

    for (const client of ports) {
      client.postMessage({ type: "count", value: count });
    }
  });

  port.start();
  port.postMessage({ type: "count", value: count });
});

The tabs use worker.port.postMessage(), not worker.postMessage(). When listeners are registered with addEventListener("message", ...), start the port with port.start(); assigning port.onmessage starts it implicitly. MDN: Using Web Workers—Shared workers

This example keeps a set of ports to demonstrate broadcasting. In a production app, send the worker’s disconnect message and close the page-side port when appropriate, and handle reconnection after navigation or page restoration. Do not treat count as durable: Shared Workers normally shut down when no contexts reference them. MDN: Shared worker lifetime

Do you actually need a Shared Worker?

If your requirement is merely “tell my other tabs that a setting changed,” a BroadcastChannel may be simpler. It broadcasts messages among eligible contexts but does not, by itself, create one central process that owns a WebSocket or in-memory state. Browser storage partitioning can also affect which contexts communicate. MDN: Broadcast Channel API

As of October 2026, MDN labels SharedWorker Baseline 2026, newly available across current major browser versions, while warning that older browsers or particular features may differ. Feature-detect it if your audience includes older devices. MDN: SharedWorker compatibility

💡 Debugging tip: Worker logs may appear in a worker-specific inspector rather than the page console. MDN points to chrome://inspect/#workers for Chrome and about:debugging#workers for Firefox. MDN: SharedWorker

Use a Shared Worker when: multiple active, eligible contexts need one live coordinator—not merely because they need to exchange an occasional message.

3. Service Workers: respond to requests and browser events

A Service Worker is registered for an origin and path scope. Once active and controlling a client, it can receive fetch events and choose whether to return a network response, a cached response, or a generated one. It also participates in supported events such as push and background sync. Unlike a Shared Worker, it is not a continuously running coordinator: the browser can stop it between events and restart it later. MDN: Service Worker API · MDN: ServiceWorkerGlobalScope

Example: an offline fallback page

For this example, serve /index.html, /app.js, /styles.css, /offline.html, and /sw.js at the root of the same origin. The named files must exist. Use HTTPS, or a browser-trusted localhost development origin. MDN: Using Service Workers

// app.js — page thread
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js", { scope: "/" })
    .catch((error) => {
      console.error("Service worker registration failed:", error);
    });
}
// sw.js — service worker
const CACHE_NAME = "offline-demo-v1";
const PRECACHE_URLS = ["/offline.html", "/app.js", "/styles.css"];

self.addEventListener("install", (event) => {
  // A failed precache rejects installation.
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) =>
      cache.addAll(PRECACHE_URLS)
    ),
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter(
            (name) =>
              name.startsWith("offline-demo-") &&
              name !== CACHE_NAME,
          )
          .map((name) => caches.delete(name)),
      ),
    ),
  );
});

self.addEventListener("fetch", (event) => {
  const request = event.request;

  // Handle only same-origin page navigations in this small example.
  if (
    request.mode !== "navigate" ||
    new URL(request.url).origin !== self.location.origin
  ) {
    return;
  }

  event.respondWith(
    (async () => {
      try {
        return await fetch(request);
      } catch {
        return (
          (await caches.match("/offline.html")) ||
          new Response("You are offline.", {
            status: 503,
            headers: { "Content-Type": "text/plain" },
          })
        );
      }
    })(),
  );
});

This is a network-first navigation with a generic offline fallback. It does not silently cache every page. That narrower policy matters: blindly storing account pages or API responses can create privacy and stale-data problems. The cache clean-up also deletes only this example’s cache names rather than every cache on the origin. MDN: Caching

Choose a cache strategy per resource

Your caching strategy is a product decision, not a default for every route. Choose it according to how fresh each resource must be and what should happen offline. MDN: Caching

Strategy Response path Potential fit Main risk
Cache first Cached response → network on miss Versioned static assets Serving stale assets if versions are mishandled
Network first Network → cached fallback Content where freshness matters Slow responses on poor connections
Stale while revalidate Cached response now; refresh for later Content that tolerates temporary staleness User sees old content until a later request
Cache only Cached response or failure Deliberately precached resources A missing cache entry breaks the request
Network only Always request network Resources you intentionally do not cache No offline response

For example, a public article and a bank balance should not inherit the same caching rule merely because both are fetched with GET.

Why the first load—and updates—behave differently

A newly registered Service Worker downloads, installs, then activates. The page that initiated registration is usually not controlled on that first load; reload or navigate to test the ordinary controlled-page behavior. clients.claim() can adopt eligible existing clients when appropriate. MDN: Using Service Workers—Initial installation

When an updated worker installs, it can remain waiting while the previous version controls open pages. skipWaiting() requests earlier activation, but can make an existing page interact with a different worker version. Do not add skipWaiting() and clients.claim() reflexively to every example: decide how your app handles version transitions first. web.dev: The service worker lifecycle

What about offline submissions and push?

Service Workers can also participate in offline submissions and push notifications, with important qualifications:

  • Background Sync can ask a Service Worker to retry queued work when connectivity is suitable, but the API has limited browser availability. Persist the queue—for example, in IndexedDB—and design a fallback such as retrying when the app opens. Do not keep unsent form data only in a Service Worker global variable. MDN: Background Synchronization API · MDN: ServiceWorkerGlobalScope

  • Push notifications are browser-managed events, not evidence that your Service Worker stays awake continuously. Check platform support and permission requirements before promising the same notification behavior to every user. MDN: Service Worker API · MDN: Push API

Use a Service Worker when: you need deliberate request handling, offline behavior, or supported event-driven capabilities. Do not use it as a home for an unbounded computation loop.

Five mistakes worth avoiding

  1. “I used a worker, so my UI cannot freeze.” The worker cannot prevent unrelated main-thread work from blocking the page; measure the actual bottleneck. web.dev

  2. “Shared Worker means persistent shared storage.” Its in-memory state can disappear when it is no longer needed. Persist important data elsewhere. MDN

  3. “My Service Worker registered, so it handles this page already.” Registration, activation, and client control are distinct. MDN

  4. “A failed HTTP response will enter my fetch() catch block.” An HTTP error such as 404 is still a response; check response.ok if your policy needs to treat it as failure. MDN: ServiceWorkerGlobalScope

  5. “Caching all GET requests is safe.” Choose routes and expiration behavior explicitly, especially for personalized content. MDN: Caching

The decision rule

Ask who owns the work:

  • One caller, expensive computation? Dedicated Web Worker.

  • Several open tabs, one live coordinator? Shared Worker.

  • Several tabs, only simple notifications between them? Consider BroadcastChannel before introducing a coordinator.

  • Controlled requests, offline responses, or supported background events? Service Worker.

  • Data that must survive tab or worker termination? Add persistent storage; none of these worker types provides that guarantee on its own.

You can combine them. An offline dashboard might use a Service Worker for navigation and assets, a Shared Worker for one live update stream across its open tabs, and a dedicated worker in a tab that performs expensive analysis. Each has one clear responsibility. MDN: Web Workers API · MDN: Service Worker API

Conclusion

The difference is not how “background” a worker sounds. It is who can reach it, what events it receives, and whether you can depend on its lifetime.

Try this next: Run the dedicated-worker example and interact with the page during calculation. Open the Shared Worker example in two tabs. Then install the Service Worker, reload, and test a navigation offline. Those three experiments reveal the architectural differences faster than another comparison table.

Further reading