DEV Community

Faraz Ali Ahmad
Faraz Ali Ahmad

Posted on

Web Workers and Service Workers: Two Different Tools for Two Different Problems

Web and Service Workers
Has your UI ever frozen for a split second while doing some heavy calculation? Or have you ever wondered how apps like Twitter or your favorite news site still "kind of work" even when your internet drops for a second?

Both of those answers live in the same neighborhood: workers. But Web Workers and Service Workers get confused constantly, and honestly, the naming is half the problem.

Both have "worker" in the name. Neither can touch the DOM directly. Both run outside the page's normal execution flow. On paper, they sound like siblings.

They're not, really. A Web Worker is about pulling expensive JavaScript off the main thread so your UI doesn't choke. A Service Worker is about controlling how your app talks to the network caching, offline behavior, push notifications, that whole world. Once you separate those two jobs in your head, most of the confusion around the APIs just disappears.

Why do we even need workers?

JavaScript runs on a main thread, and that thread is busy it's rendering your page, listening for clicks, keeping scroll smooth, all of it at once.

Ask it to also parse a massive JSON blob, or churn through image processing, or run some CPU-heavy loop, and things start to visibly break. Buttons stop responding. Animations stutter. Users assume the tab crashed.

Web Workers exist to fix exactly that. Service Workers exist to fix something else entirely, which is why lumping them together causes so much confusion in the first place.

Web Workers: Running Work Off the Main Thread

Web Workers
A Web Worker is basically a second JavaScript context running alongside your page its own thread, its own memory, no DOM access at all. That last part isn't a limitation someone forgot to fix, it's deliberate. The whole point of a worker is that it doesn't have to compete with the main thread for anything.

People reach for Web Workers for things like:

  • Processing large datasets
  • Parsing or transforming big files
  • Image or video work
  • Compression, encryption
  • Any CPU-heavy math simulations, chart crunching, that kind of thing

Here's is an example of recursive Fibonacci function because it is slow and large computation:

main.js

// Create a new Web Worker. The worker runs the code inside worker.js on a separate thread.
const worker = new Worker("./worker.js");

// Send a message from the main thread to the worker. The worker will receive this object in its "message" event.
worker.postMessage({ number: 45 });

// Listen for the result sent back from the worker.
worker.onmessage = (event) => {
  console.log("Result from worker:", event.data);
};

// Handle any errors that occur inside the worker.
worker.onerror = (error) => {
  console.error("Worker error:", error.message);
};
Enter fullscreen mode Exit fullscreen mode

worker.js

// Listen for messages coming from the main thread.
self.onmessage = (event) => {
  // Get the number sent by the main thread.
  const number = event.data.number;

  // Run the CPU-heavy Fibonacci calculation.
  const result = fibonacci(number);

  // Send the result back to the main thread.
  self.postMessage(result);
};

// A deliberately inefficient recursive Fibonacci function.
// The larger the number, the more work it has to do.
function fibonacci(n) {
  // Base case: Fibonacci(0) = 0 and Fibonacci(1) = 1.
  if (n <= 1) return n;

  // Recursively calculate the previous two Fibonacci numbers.
  return fibonacci(n - 1) + fibonacci(n - 2);
}
Enter fullscreen mode Exit fullscreen mode

The page fires off a message with postMessage(), the worker grinds through the math on its own thread, and sends the answer back whenever it's done.

A few things about this that are easy to miss the first time around:

You genuinely cannot reach the DOM from inside a worker. document.querySelector(...) just doesn't exist in that context.

Data passed through postMessage() isn't shared by reference : it goes through structured cloning, so you're really sending a copy. Doesn't matter much for small payloads, but if you're shuttling around large arrays or buffers, it's worth knowing that a copy is being made each time.

Workers aren't free, either. Spinning one up costs memory and setup time, so "wrap everything in a worker" isn't a real performance strategy — it's just moving the cost around, and sometimes adding to it. Call worker.terminate() when you're actually done with one; leaving dead workers around is the kind of thing that quietly bloats memory over a long session.

There's also SharedWorker, which multiple tabs from the same origin can talk to at once. Most people never need it, but it's worth knowing it exists for the day you do.

Service Workers: Handling Network Requests and Browser Events

Service Workers

This is where the name actively works against you. A Service Worker isn't a general-purpose background thread you throw tasks at. It's much more specific like it's a layer that sits between your app and the network, and it also happens to hook into browser-level events like push notifications and background sync.

It acts as a network proxy between the app and the internet. Every network request your page makes can pass through it first, and it gets to decide what happens — serve from cache, hit the network, do both and race them, whatever you've coded it to do.

That's the foundation for:

  • Offline experiences
  • Caching static assets
  • Fallback strategies when the network is flaky
  • Push notifications
  • Background sync
  • Making repeat visits noticeably faster

Registering a Service Worker is straightforward. The interesting part is what happens after registration.

main.js

// Check whether the browser supports Service Workers.
if ("serviceWorker" in navigator) {
  window.addEventListener("load", async () => {
    try {
      // Register the Service Worker and get its registration details.
      const registration = await navigator.serviceWorker.register("/sw.js");

      console.log("Service Worker registered:", registration.scope);
    } catch (error) {
      console.error("Service Worker registration failed:", error);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

The real work happens inside the Service Worker file, where it responds to lifecycle and network events.

sw.js

const CACHE_NAME = "my-app-cache-v1";

const urlsToCache = ["/", "/styles.css", "/app.js", "/logo.png"];

// Cache the application's essential assets during installation.
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(urlsToCache);
    }),
  );
});

// Intercept requests and return a cached response when available.
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    }),
  );
});

// Remove caches from older versions when the new worker becomes active.
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name)),
      );
    }),
  );
});
Enter fullscreen mode Exit fullscreen mode

Three events are doing most of the work here:

  • install — prepares the initial cache.
  • activate — cleans up caches from older versions.
  • fetch — intercepts requests and determines how they should be handled.

One detail that often causes confusion is the Service Worker's lifetime. It doesn't run continuously in the background like a persistent process. The browser starts it when there's an event to handle and may stop it when it's idle.

That's why you shouldn't rely on in-memory state surviving between events. The next event may be handled by a new Service Worker instance.

Caching Strategies

The fetch handler above grabs from cache first and only falls back to the network if nothing's there. That's a fine default, but it's one strategy out of a few worth knowing:

Cache-first works well for stuff that barely changes fonts, logos, versioned bundles. Check the cache, only bother the network if it's missing.

Network-first flips it try the network, fall back to cache only if that fails. Makes sense when freshness matters more than speed, but you still want something to show if the connection drops.

Stale-while-revalidate serves whatever's cached immediately, and quietly fetches an updated version in the background for next time. Nice middle ground for something like a feed, where instant load matters but the data doesn't need to be perfectly current every single time.

None of this needs to be memorized. Just match the strategy to what you're actually caching an API response, a JS bundle, and a logo don't deserve identical treatment.

Web Workers vs Service Workers

Web Worker Service Worker
Primary job CPU-heavy computation Network and browser event handling
DOM access No No
Intercepts network requests No Yes
Offline support No Yes
Push notifications No Yes
Typical lifetime Tied to the page that created it Browser-managed, event-driven
Common example Crunching a large dataset Serving cached assets
Communication postMessage() Events, postMessage(), browser APIs
Scope The context that created it A URL scope within an origin

Things That Are Easy to Get Wrong

A surprising number of people assume Service Workers can reach into the DOM somehow, maybe because "worker" sounds active and hands-on. They can't neither worker type can, and if they need to talk to the page, it's through messaging, not direct manipulation.

There's also a belief that adding a Web Worker automatically makes things faster. It can help, sure, by keeping heavy work off the main thread but if you're shuttling large amounts of data back and forth constantly, that communication overhead can eat into the gains you were hoping for. More workers isn't automatically better either, each one has real memory cost.

Then there's the assumption that Service Workers cache things automatically just by existing. They don't cache a single byte until you write the logic yourself, install handler, fetch handler, the works. No code, no caching.

And finally, plenty of people genuinely think these two are more or less interchangeable because of the shared name. They're not. One is about computation, the other is about network control, and that's really the whole distinction worth holding onto.

Summary: Which Worker Should You Use?

Say you're building something that pulls down a large JSON export for an analytics dashboard. A Service Worker can decide whether that request should come from cache or hit the network fresh. Once the data actually lands on the page, a Web Worker can take over the expensive part like parsing, transforming, running whatever calculations without any of it fighting the main thread for CPU time.

They're not alternatives to each other. One manages how resources move in and out of your app and the other manages what happens to that data once it's there. Different layers, different problems, and honestly a pretty clean division of labor once you see it working in a real app.

Worth a quick caveat here too: not every Service Worker API has consistent support across browsers Background Sync in particular is still patchy in places. Check what you actually need before building a feature around it.

Top comments (0)