Home / Articles / Dedicated, Shared and Service Workers: Picking the Right Browser Thread

This article is published in English.

Dedicated, Shared and Service Workers: Picking the Right Browser Thread

Understand how dedicated, shared and service workers differ in scope, messaging and purpose, and learn when each one actually improves a web application.

2425 words

Page JavaScript runs on one main thread, yet modern apps stay responsive while crunching large files, work offline and receive push notifications while idle. Much of that comes from workers, which run script outside the main thread. But "worker" covers three different tools, and picking the wrong one wastes effort. This guide shows what each type is for, how it communicates, and when it is not worth using.

The family has three members:

Web Workers
     │
     ├── Dedicated Worker
     │
     ├── Shared Worker
     │
     └── Service Worker

Why the main thread needs help

By default, your code shares one thread with DOM updates, input handling, rendering and animation:

JavaScript
   ↓
DOM updates
   ↓
User interactions
   ↓
Rendering
   ↓
Animations

Because those jobs take turns, a long synchronous call such as the one below holds the thread until it returns:

const result = expensiveCalculation();

Until it finishes, the browser cannot handle input or paint a frame. The user experiences this:

User clicks button
        ↓
Heavy JavaScript starts
        ↓
Main thread is busy
        ↓
UI becomes sluggish
        ↓
Calculation finishes
        ↓
UI becomes responsive again

A worker solves this by executing JavaScript in its own separate context, so the main thread stays free for interface work.

How a page and a worker cooperate

Picture two threads joined by a message channel: the main thread owns UI, DOM, events and rendering, and the worker takes calculations, parsing and processing:

                 Browser
                    │
          ┌─────────┴─────────┐
          │                   │
          ↓                   ↓
     Main Thread          Worker Thread
          │                   │
     UI / DOM             Heavy Work
     Events               Calculations
     Rendering             Parsing
     Interaction           Processing
          │                   │
          └──── Messages ─────┘

The page sends data into a worker by calling postMessage on the worker object:

worker.postMessage(data);

Inside the worker, the global postMessage sends the result back to the page:

postMessage(result);

The two sides share no ordinary variables; everything crosses as a message.

Why workers cannot touch the DOM

A worker's global scope has no access to these:

document
window
DOM elements

So a line like this inside a worker file simply fails, because document is not defined there:

// worker.js

document.querySelector("#app");

The pattern instead is to do the computation in the worker, post the result, and let the main thread apply it to the page:

Main Thread
     │
     │ postMessage(data)
     ↓
   Worker
     │
     │ performs calculation
     ↓
   Worker
     │
     │ postMessage(result)
     ↓
Main Thread
     │
     ↓
Update DOM

Keeping DOM ownership on one thread means background code can never change the interface mid-render.

The three worker types at a glance

Their scopes differ: one creator, several same-origin contexts, or the network layer:

┌──────────────────────────────┐
│        Web Workers           │
├──────────────────────────────┤
│                              │
│  Dedicated Worker            │
│  → One script/client         │
│                              │
│  Shared Worker               │
│  → Multiple same-origin      │
│    browsing contexts         │
│                              │
│  Service Worker              │
│  → Network / caching /       │
│    offline / background      │
│    capabilities              │
│                              │
└──────────────────────────────┘

Dedicated workers for heavy computation

A dedicated worker is owned by the script that constructs it. When a team says "move that calculation into a worker", this is the type they mean.

On the page side, you construct the worker from a script URL, send it a number and log whatever comes back:

const worker = new Worker("worker.js");
worker.postMessage(1000000);
worker.onmessage = (event) => {
    console.log("Result:", event.data);
};

The worker listens for messages, sums every integer below the value it receives and replies with the total:

// worker.js

self.onmessage = (event) => {
    const number = event.data;
    let result = 0;
    for (let i = 0; i < number; i++) {
        result += i;
    }
    self.postMessage(result);
};

The round trip is straightforward:

Main Thread
     │
     │ 1000000
     ↓
Dedicated Worker
     │
     │ Calculate
     ↓
   Result
     │
     ↓
Main Thread

The worker is tied to its creator and never shared with unrelated pages; two tabs get two independent workers.

Good candidates for a dedicated worker

They pay off for CPU-bound work in one application. Transforming a large API payload is typical: the worker reshapes the data and the main thread only renders it.

API Response
     ↓
Large JSON
     ↓
   Worker
     ↓
Parse / Transform
     ↓
Main Thread
     ↓
Render UI

Image resizing and filtering follow the same shape:

Image
  ↓
Worker
  ↓
Resize / Transform
  ↓
Result
  ↓
 UI

So do filtering, sorting and aggregating big datasets:

Large Dataset
      ↓
    Worker
      ↓
  Filtering
   Sorting
 Aggregation
      ↓
     UI

Encryption, compression, large-file parsing and heavy calculations fit too. The rule: if CPU-intensive work makes the interface stutter, consider a dedicated worker.

Shared workers for several tabs

Now suppose the same application is open in several browsing contexts at once:

Tab A
Tab B
Tab C

A shared worker lets scripts in several windows, tabs or iframes connect to one worker, provided they share an origin:

             Shared Worker
              /    |    \
             /     |     \
          Tab A   Tab B   Tab C

Without it, each tab spins up its own copy:

Tab A → Worker A
Tab B → Worker B
Tab C → Worker C

With it, every tab connects to one instance:

Tab A ─┐
Tab B ─┼──> Shared Worker
Tab C ─┘

Communicating through ports

A shared worker is reached through an explicit MessagePort. The page starts the port and sends a message:

const worker = new SharedWorker("worker.js");
worker.port.start();
worker.port.postMessage("Hello");

Each new client fires a connect event in the worker, whose handler listens on that client's port:

self.onconnect = (event) => {
    const port = event.ports[0];
    port.onmessage = (event) => {
        console.log(event.data);
    };
};

The port is the main practical difference from a dedicated worker: with many clients, each connection needs its own channel, and replies go back on the right tab's port.

A multi-tab scenario

Picture an internal tool where different tabs show different views:

Tab 1 → Dashboard
Tab 2 → Reports
Tab 3 → Analytics

A single background component could hold common state or coordinate communication for all of them:

              Shared Worker
                   │
        ┌──────────┼──────────┐
        ↓          ↓          ↓
     Tab 1       Tab 2      Tab 3
        │          │          │
        └──────────┼──────────┘
                   ↓
             Shared State

That spares each tab its own worker instance, but every context must share an origin. Shared worker support has also historically lagged dedicated workers, notably on some mobile browsers, so check current compatibility data first.

Service workers for network and offline behavior

The service worker is the most misunderstood type. It is not a renamed dedicated worker; it sits between your application, the browser and the network:

Browser
   │
   ↓
Service Worker
   │
   ├── Cache
   │
   ├── Network
   │
   ├── Offline response
   │
   └── Background capabilities

Because it intercepts requests, it underpins offline experiences, caching, custom request handling, push notifications and background sync.

Sitting between the page and the server

Say a user visits your site:

https://example.com

Without a service worker, each request travels straight from the browser to the server:

Browser
   ↓
Internet
   ↓
Server

With one registered, requests pass through it first, and it can serve them from a cache or forward them to the network:

Browser
   ↓
Service Worker
   ↓
   ├── Cache
   │
   └── Network

It decides each request's fate. A cache-first strategy returns hits immediately and otherwise fetches, caching where appropriate:

Request
   ↓
Is it cached?
   │
   ├── YES → Return cached response
   │
   └── NO
       ↓
     Network
       ↓
     Response
       ↓
     Cache if appropriate

Offline-capable apps are built on this logic. For a walkthrough, see enabling offline support in web apps with service workers.

Registration and lifecycle

The browser manages a lifecycle, simplified here:

Register
   ↓
Download
   ↓
Install
   ↓
Activate
   ↓
Control Pages

You begin by registering a script when the API exists:

if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("/sw.js");
}

The browser then handles installation, activation and updates. A dedicated worker, by contrast, comes from a direct constructor call, which never applies to service workers:

new Worker(...)

They require a secure context (HTTPS, with localhost allowed in development). A new worker does not control already-open pages until reload unless it claims them, which often confuses testing.

Intercepting requests

A minimal service worker listens for fetch events; this one just logs each URL:

self.addEventListener("fetch", (event) => {
    console.log("Request:", event.request.url);
});

Real caching builds on that hook. For app.js: serve from cache if present, otherwise fetch, store and return:

User requests app.js
        ↓
Service Worker
        ↓
Is app.js cached?
     /       \
   YES        NO
    ↓          ↓
 Return      Network
 Cache          ↓
              Cache
                ↓
             Return

Hence their central role in Progressive Web Apps (PWAs) and offline-first designs.

Comparing the three side by side

Dedicated worker

The short version is "a background thread that belongs to one page":

 Page
  │
  ↓
Dedicated Worker

It fits CPU-heavy calculations, parsing, image processing and data processing.

Shared worker

The short version is "one worker that several same-origin pages can use":

Tab A ─┐
Tab B ─┼──> Shared Worker
Tab C ─┘

It fits background work shared across browsing contexts, and shared communication or state.

Service worker

The short version is "a layer between the application and the network":

App
 ↓
Service Worker
 ↓
Cache / Network

It fits offline applications, caching, request interception, push notifications and background sync.

The one-line summary of each

In one line each:

Dedicated Worker
        ↓
"Do this heavy computation for me."

Shared Worker
        ↓
"Let multiple pages use this worker."

Service Worker
        ↓
"Help my web application interact with
the network and browser capabilities."

Framed that way, the three are hard to mix up.

Scope, messaging and typical use

Dedicated: one script, background computation, postMessage(), no DOM:

Scope:
One script

Main purpose:
Background computation

Communication:
postMessage()

DOM access:
❌ No

Common use:
Heavy computation

Shared: several same-origin contexts, MessagePort, no DOM, cross-tab coordination:

Scope:
Multiple same-origin contexts

Main purpose:
Shared background work

Communication:
MessagePort

DOM access:
❌ No

Common use:
Cross-tab/shared worker communication

Service: pages within its origin and path scope, events and platform APIs, no DOM, caching, offline, push and sync:

Scope:
Origin/path controlled pages

Main purpose:
Network + background capabilities

Communication:
Events / messaging / APIs

DOM access:
❌ No

Common use:
Caching, offline, push, background sync

When a worker makes things slower

Workers are not automatically faster. Startup costs something, and data must travel between contexts:

 Main Thread
      ↕
    Worker

That messaging takes time too. For a small task, the overhead can outweigh the work itself:

Small Task
   ↓
Worker overhead
   ↓
Communication
   ↓
Actual calculation

Then the worker may be slower than inline code. Use one only when the task is heavy enough that offloading it yields a visible gain.

What actually crosses the boundary

Messages are copied with the structured clone algorithm, so large payloads cost copy time. Transferables such as ArrayBuffer move without copying, but the sender loses access. SharedArrayBuffer allows real shared memory only under extra security requirements (cross-origin isolation). For most apps, keep to explicit messages:

Main Thread
    ↓
postMessage()
    ↓
  Worker
    ↓
postMessage()
    ↓
Main Thread

Using a dedicated worker in React

If a React app processes a large dataset during render or in a handler, the UI stalls:

React UI
   ↓
Large calculation
   ↓
Main thread blocked
   ↓
UI becomes sluggish

With a dedicated worker, the component posts data and updates state when the result arrives:

React UI
   │
   ├──────────────> Worker
   │                  │
   │                  ↓
   │              Calculation
   │                  │
   │                  ↓
   │<──────────── Result
   │
   ↓
Update UI

React keeps rendering while the worker computes. Create the worker in an effect and call terminate() in its cleanup so it does not outlive the component. This suits data visualization, image editors, audio and video processing, large files and client-side analytics.

Choosing the right worker

CPU-heavy JavaScript points to a dedicated worker:

Do you have CPU-heavy JavaScript?
          │
         YES
          ↓
   Use Dedicated Worker

If the requirement sounds like this:

Multiple tabs/pages need
the same worker

then the type to evaluate is:

Shared Worker

And if the requirement involves any of these:

Caching
Offline support
Network interception
Push notifications
Background sync

then the answer is:

Service Worker

Common mistakes

Moving everything into a worker

Use workers only where they solve a measurable problem.

Expecting DOM access

This cannot work:

worker.document.querySelector(...);

Workers send data back; the main thread updates the DOM.

Using a service worker for computation

Service workers target the network and app lifecycle, and the browser may stop them when idle. For pure number crunching such as:

1 million records
       ↓
complex calculation

start with a dedicated worker.

Ignoring messaging costs

Each exchange has overhead:

Main Thread
     ↕
Messaging
     ↕
  Worker

Move only work large enough to cover it.

Wrapping up

The guiding principle: expensive JavaScript should not block the main thread without reason. The three types map to three problems:

                    Web Workers
                         │
          ┌──────────────┼──────────────┐
          ↓              ↓              ↓
     Dedicated         Shared         Service
       Worker          Worker          Worker
          │              │              │
     One script      Multiple        Network /
     uses it         contexts        offline
  • Dedicated worker: heavy computation for one page.
  • Shared worker: background work shared by several same-origin contexts.
  • Service worker: network interception, caching and offline capabilities.

Before adopting one, measure the blocking task, confirm it outweighs messaging overhead, and check browser support. Seen this way, workers are three specific answers to three distinct questions.