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.

Dedicated workers vs one shared worker Comparison of three tabs each running a dedicated worker with three tabs connected to a single shared worker. Dedicated workers vs one shared worker Dedicated worker per tab • 3 tabs = 3 workers, 3 indexes • 3 WebSocket connections • Each new tab pays full startup • Memory scales with tab count One SharedWorker • 3 tabs share 1 worker • 1 connection fanned out to all tabs • Later tabs connect to a warm worker • Memory roughly constant

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.

Two tabs connecting to one SharedWorker Sequence showing the first tab starting the shared worker and building state, and a second tab connecting to the already-warm worker. Two tabs connecting to one SharedWorker Tab A SharedWorker Tab B Server connect (cold start) one WebSocket connect (warm) update update

Step-by-Step Resolution

javascript
// 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

javascript
// 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

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

Resource use with three open tabs Bar chart comparing memory used by data workers with three tabs open, using dedicated workers versus one shared worker. Resource use with three open tabs Dedicated workers (3 tabs) 186MB SharedWorker (3 tabs) 68MB SharedWorker (1 tab) 62MB

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.