How to Share One Worker Across Tabs with SharedWorker
This guide extends Offloading Work to Web Workers with Comlink within Core Web Vitals & Measurement to multi-tab applications. Dashboards, email clients, trading tools and admin panels are often open in several tabs at once. If each tab starts its own worker to maintain a WebSocket, build a search index or cache API data, the device does that work once per tab — multiplying CPU, memory and network use.
A SharedWorker is created once per origin and script URL; every tab that constructs it connects to the same instance through a MessagePort. Expensive state — a large index, a live connection, a decoded dataset — exists once. New tabs connect to an already-warm worker, which can turn a 600ms startup into an instant one for every tab after the first.
Rapid Diagnosis
- Count tabs per user. If your analytics shows many sessions with multiple concurrent tabs (common in B2B tools), the duplication is real.
- Measure per-tab startup work. In a trace of opening a second tab, look for the same worker startup, fetch and indexing as in the first.
- Check memory. In Chrome's Task Manager, compare memory with one tab and with three tabs open. Linear growth in worker memory points to duplicated state.
- Check connection counts. Multiple identical WebSocket or SSE connections from one browser are a strong signal.
- Check browser support for your audience. SharedWorker is supported in Chromium desktop, Firefox and Safari 16+, but not in Chrome for Android. Mobile Chromium users need a fallback.
Root Cause Analysis
1. Per-tab ownership of shared state. Each tab independently fetches and processes the same data because nothing coordinates them.
2. Per-tab real-time connections. Each tab opens its own socket, multiplying server load and client CPU for message parsing.
3. Cold start on every tab. Every new tab pays worker script download (usually cached), evaluation and state construction.
4. Cross-tab sync through storage events. Tabs coordinate by writing to localStorage and listening for storage events, which is synchronous on the main thread and slow for large values.
Step-by-Step Resolution
1. Create the shared worker with Comlink
// main.js
import * as Comlink from 'comlink';
const shared = new SharedWorker(new URL('./data-worker.js', import.meta.url), { type: 'module', name: 'data' });
const api = Comlink.wrap(shared.port);
const results = await api.search('invoice 2041');
// data-worker.js
import * as Comlink from 'comlink';
const state = createState(); // built once for all tabs
onconnect = (e) => Comlink.expose(state.api, e.ports[0]);
// trade-off: every tab shares one thread. A long task in the shared worker
// delays responses to ALL tabs; keep its work chunked just as you would on
// the main thread, especially if tabs issue concurrent requests.
Expected outcome: the second and later tabs get results without rebuilding state.
2. Fan out real-time data from a single connection
// data-worker.js
const ports = new Set();
const ws = new WebSocket('wss://api.example.com/stream');
ws.onmessage = (m) => { const update = JSON.parse(m.data); for (const p of ports) p.postMessage(update); };
onconnect = (e) => { const port = e.ports[0]; ports.add(port); port.start(); };
// trade-off: ports of closed tabs are not reliably removed — there is no
// 'close' event on a MessagePort. Have tabs send a 'bye' on pagehide and prune
// ports that fail to respond to a periodic ping.
Expected outcome: one socket per browser regardless of tab count; JSON parsing of stream messages happens once, off every main thread.
3. Provide a fallback where SharedWorker is missing
function createDataWorker() {
if ('SharedWorker' in globalThis) {
const sw = new SharedWorker(new URL('./data-worker.js', import.meta.url), { type: 'module' });
return sw.port;
}
return new Worker(new URL('./data-worker.js', import.meta.url), { type: 'module' });
}
// The worker script handles both: in a dedicated worker, treat `self` as the single port.
// trade-off: the fallback loses deduplication on Chrome for Android. That is
// acceptable — mobile users rarely keep many tabs of a dashboard open.
Expected outcome: one code path that degrades to a per-tab worker where needed.
4. Keep state warm across tab closures
A SharedWorker is terminated when its last connected tab closes. If users close and reopen the app frequently, consider persisting expensive derived state (a serialised index) to IndexedDB from the worker so a cold start restores rather than rebuilds.
Expected outcome: even cold starts become fast after the first visit.
Verification
Open three tabs and compare against the dedicated-worker version: worker count in Task Manager, WebSocket connections in the Network panel, and startup trace of the third tab. The third tab should show no state construction. Measure INP in each tab during bursts of real-time updates; it should be unaffected because parsing happens in the shared worker.
SharedWorker vs Service Worker vs BroadcastChannel
These three are often confused. A service worker intercepts network requests and can outlive all tabs, but it is event-driven and can be stopped by the browser at any time when idle — unsuitable for holding long-lived state or connections. A BroadcastChannel lets tabs message each other but runs no code of its own; every tab still does its own work, and leader election (choosing one tab to own the connection) is your responsibility. A SharedWorker is a real, persistent thread shared by connected tabs, which is what you need for deduplicating computation or connections. For many apps, a SharedWorker for state and connections plus a service worker for caching is the cleanest division of labour.
Handling Errors and Restarts
A crash in a dedicated worker affects one tab; a crash in a shared worker affects every connected tab at once. Wrap message handlers in try/catch and report errors back to the requesting port rather than letting them become unhandled. Listen for the error event on the SharedWorker object in each tab to detect a worker that failed to start. And because a shared worker can be terminated when the last tab closes and recreated when one opens, never assume in-memory state survives between visits — persist anything expensive to rebuild, and design the startup path so that rebuilding is correct, merely slower.
FAQ
Does a SharedWorker help single-tab users?
No. With one tab it behaves like a dedicated worker. Its benefit is entirely about deduplication across tabs (and iframes of the same origin), so measure how often your users actually have multiple tabs open before adopting it.
Can I debug a SharedWorker in DevTools?
Yes: chrome://inspect/#workers lists shared workers with an "inspect" link that opens dedicated DevTools for the worker thread. Console output from the worker appears there, not in the tabs' consoles.
What happens to the shared worker on a deploy?
Tabs loaded before the deploy are connected to the old worker script; new tabs loading a different (hashed) script URL create a separate shared worker instance. Both run side by side until the old tabs close. Version your message protocol so old tabs and new workers never talk to each other by accident.
Can iframes connect to the same SharedWorker?
Yes, if they are same-origin with the worker script. Embedded widgets of your own origin can share state with the host page this way. Cross-origin iframes cannot connect, and in some browsers storage partitioning means a third-party context gets a separate instance even for the same script URL.
Related
- Bundling Web Workers with Vite and Webpack — emitting the shared worker script correctly.
- Running client-side search in a Web Worker — a workload that benefits from sharing.
- Service Worker caching strategies — the other kind of worker, for the network layer.