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.
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.