`Shared Worker` in TypeScript

`Shared Worker` in TypeScript

This article explains Shared Worker in TypeScript.

We will explain in detail how Shared Workers work and how to use them in practice, with TypeScript code examples.

YouTube Video

Shared Worker in TypeScript

Shared Worker is a single worker process shared across multiple tabs, windows, and iframes on the same origin. By using this, you can handle shared state and resources across multiple browser tabs.

For example, you can efficiently implement a shared WebSocket connection, cache and queue processing synchronized across tabs, and mutual exclusion.

Unlike a Dedicated Worker, a Shared Worker receives multiple MessagePorts via the onconnect event and can multiplex communication with multiple clients.

Cases where you should choose a Shared Worker

In the following cases, using a Shared Worker is appropriate.

  • When you need shared state or mutual exclusion across tabs
  • When you want to share a single WebSocket connection or IndexedDB access
  • When you need to notify all tabs (broadcast)
  • When you want to centralize heavy processing to conserve resources

Conversely, in the following cases, other approaches are more suitable.

  • When you need cache control or offline support, you can use a Service Worker.
  • For heavy processing that is confined to a single tab, you can use a Dedicated Worker.

Implementation steps for Shared Worker

Here, we'll implement the following step by step using TypeScript.

  • Type-safe message protocol
  • Promise-based request/response (RPC)
  • Broadcast to all tabs
  • Heartbeat and client cleanup

Environment setup

Create the configuration to compile each source file that uses a Shared Worker.

 1{
 2  "compilerOptions": {
 3    "target": "ES2020",
 4    "module": "ES2020",
 5    "lib": ["ES2020", "dom", "WebWorker"],
 6    "moduleResolution": "Bundler",
 7    "strict": true,
 8    "noEmitOnError": true,
 9    "outDir": "out",
10    "skipLibCheck": true
11  },
12  "include": ["src"]
13}
  • In tsconfig-src.json, enable DOM and Web Worker type definitions so the code can be compiled safely.

Defining the message protocol

The foundation of communication is a typed message contract. Defining this upfront makes subsequent communication safe and easy to extend.

 1// worker-protocol.ts
 2// Define discriminated unions for structured messages
 3
 4export type RequestAction =
 5  | { type: 'ping' }
 6  | { type: 'echo'; payload: string }
 7  | { type: 'getTime' }
 8  | { type: 'set'; key: string; value: unknown }
 9  | { type: 'get'; key: string }
10  | { type: 'broadcast'; channel: string; payload: unknown }
11  | { type: 'lock.acquire'; key: string; timeoutMs?: number }
12  | { type: 'lock.release'; key: string };
  • RequestAction is a discriminated union (tagged union) that represents the types of requests sent to the worker and defines operations such as ping, get, and broadcast.
1export interface RequestMessage {
2  kind: 'request';
3  id: string;
4  from: string;
5  action: RequestAction;
6}
  • RequestMessage defines the structure of request messages sent from the client to the worker.
1export interface ResponseMessage {
2  kind: 'response';
3  id: string;
4  ok: boolean;
5  result?: unknown;
6  error?: string;
7}
  • ResponseMessage defines the structure of response messages returned from the worker to the client.
1export interface BroadcastMessage {
2  kind: 'broadcast';
3  channel: string;
4  payload: unknown;
5  from: string;
6}
  • BroadcastMessage defines the structure of broadcast messages that the worker sends to other clients.
1export type WorkerInMessage =
2  | RequestMessage
3  | { kind: 'heartbeat'; from: string }
4  | { kind: 'bye'; from: string };
  • WorkerInMessage is a type that represents all messages the worker receives, such as requests, heartbeats, and disconnect notifications.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
  • WorkerOutMessage is a type that represents response or broadcast messages the worker sends to the client.
1export const randomId = () => Math.random().toString(36).slice(2);
  • randomId is a function that generates a random alphanumeric string to use for message IDs and the like.

Implementing the Shared Worker

In shared-worker.ts, register tabs that connect via the onconnect event and handle messages.

1// shared-worker.ts
2/// <reference lib="webworker" />
  • This directive instructs TypeScript to load type definitions for Web Workers.
1import {
2  WorkerInMessage,
3  WorkerOutMessage,
4  RequestMessage,
5  ResponseMessage,
6} from './worker-protocol.js';
  • Imports the type definitions used for worker communication.
1export default {};
2declare const self: SharedWorkerGlobalScope;
  • Explicitly declares that self is the global scope of the Shared Worker.
1type Client = {
2  id: string;
3  port: MessagePort;
4  lastBeat: number;
5};
  • Client is a type that represents each client's identifier, communication port, and last heartbeat timestamp.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
  • Manages the list of connected clients, a key-value store, lock state, and timeout durations.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2  port.postMessage(msg);
3}
  • send is a utility function that sends a message to the specified port.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2  return { kind: 'response', id: req.id, ok, result, error };
3}
  • respond generates a response message for a request.
1function broadcast(from: string, channel: string, payload: unknown) {
2  for (const [id, c] of clients) {
3    send(c.port, { kind: 'broadcast', channel, payload, from });
4  }
5}
  • broadcast sends a message on a specified channel to all clients.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
  • handleRequest processes incoming requests by type and returns the results to the client.
 1  const { action } = req;
 2  try {
 3    switch (action.type) {
 4      case 'ping':
 5        send(port, respond(req, true, 'pong'));
 6        break;
 7      case 'echo':
 8        send(port, respond(req, true, action.payload));
 9        break;
10      case 'getTime':
11        send(port, respond(req, true, new Date().toISOString()));
12        break;
13      case 'set':
14        kv.set(action.key, action.value);
15        send(port, respond(req, true, true));
16        break;
17      case 'get':
18        send(port, respond(req, true, kv.get(action.key)));
19        break;
20      case 'broadcast':
21        broadcast(clientId, action.channel, action.payload);
22        send(port, respond(req, true, true));
23        break;
  • In this code, depending on the type of request received, it handles sending and receiving messages, retrieving and saving data, and broadcasting.
 1      case 'lock.acquire': {
 2        const owner = locks.get(action.key);
 3        if (!owner) {
 4          locks.set(action.key, clientId);
 5          send(port, respond(req, true, { owner: clientId }));
 6        } else if (owner === clientId) {
 7          send(port, respond(req, true, { owner }));
 8        } else {
 9          const start = Date.now();
10          const tryWait = () => {
11            const current = locks.get(action.key);
12            if (!current) {
13              locks.set(action.key, clientId);
14              send(port, respond(req, true, { owner: clientId }));
15            } else if ((action.timeoutMs ?? 5000) < Date.now() - start) {
16              send(port, respond(req, false, undefined, 'lock-timeout'));
17            } else {
18              setTimeout(tryWait, 25);
19            }
20          };
21          tryWait();
22        }
23        break;
24      }
  • This code implements the process for a client to acquire a lock for the specified key. If the lock is not already held, it is acquired immediately; if the same client requests it again, the request is also treated as successful. If another client already holds the lock, it retries every 25 milliseconds until the lock is released, and if the specified timeout (default 5 seconds) is exceeded, it responds with an error.
 1      case 'lock.release':
 2        if (locks.get(action.key) === clientId) {
 3          locks.delete(action.key);
 4          send(port, respond(req, true, true));
 5        } else {
 6          send(port, respond(req, false, undefined, 'not-owner'));
 7        }
 8        break;
 9      default:
10        send(port, respond(req, false, undefined, 'unknown-action'));
11    }
12  } catch (e: any) {
13    send(port, respond(req, false, undefined, e?.message ?? 'error'));
14  }
15}
  • This code releases the lock held by the client and returns an error response if the client lacks permission or the action is unknown.
1function handleInMessage(c: Client, msg: WorkerInMessage) {
2  if (msg.kind === 'heartbeat') {
3    c.lastBeat = Date.now();
4  } else if (msg.kind === 'bye') {
5    cleanupClient(msg.from);
6  } else if (msg.kind === 'request') {
7    handleRequest(c.id, c.port, msg);
8  }
9}
  • handleInMessage parses messages received from clients and handles requests and heartbeats.
1function cleanupClient(clientId: string) {
2  for (const [key, owner] of locks) {
3    if (owner === clientId) locks.delete(key);
4  }
5  clients.delete(clientId);
6}
  • cleanupClient removes disconnected clients from the registry and lock state.
1setInterval(() => {
2  const now = Date.now();
3  for (const [id, c] of clients) {
4    if (now - c.lastBeat > HEARTBEAT_TIMEOUT) cleanupClient(id);
5  }
6}, 10_000);
  • Uses setInterval to periodically check all clients' heartbeats and clean up connections that have timed out.
 1self.onconnect = (e: MessageEvent) => {
 2  const port = (e.ports && e.ports[0]) as MessagePort;
 3  const clientId = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
 4  const client: Client = { id: clientId, port, lastBeat: Date.now() };
 5  clients.set(clientId, client);
 6
 7  port.addEventListener('message', (ev) => handleInMessage(client, ev.data as WorkerInMessage));
 8  send(port, { kind: 'broadcast', channel: 'system', payload: { hello: true, clientId }, from: 'worker' });
 9  port.start();
10};
  • onconnect is called when a new tab or page connects to the Shared Worker, registering the client and starting communication.

  • Across this file, the fundamental mechanisms of a Shared Worker that enable shared state management and communication across multiple browser tabs are implemented.

Client wrapper (RPC)

Next, create a Promise-based RPC client.

1// shared-worker-client.ts
2import {
3  RequestAction,
4  RequestMessage,
5  WorkerOutMessage,
6  randomId
7} from './worker-protocol.js';
  • Imports the type definitions and utility functions used for worker communication.
1export type BroadcastHandler = (msg: {
2  channel: string;
3  payload: unknown;
4  from: string
5}) => void;
  • Here we define the type of the callback function that runs when a broadcast message is received.
1export class SharedWorkerClient {
  • SharedWorkerClient is a client class that communicates with a Shared Worker, sending requests and handling responses.
1  private worker: SharedWorker;
2  private port: MessagePort;
3  private pending = new Map<string, {
4    resolve: (v: any) => void;
5    reject: (e: any) => void
6  }>();
  • These variables are the worker instance, the communication port with the worker, and a map that tracks requests awaiting responses.
1  private clientId = randomId();
2  private heartbeatTimer?: number;
3  private onBroadcast?: BroadcastHandler;
  • These variables hold the client identifier, the timer for sending heartbeats, and the broadcast reception handler.
1  constructor(url: URL, name = 'app-shared', onBroadcast?: BroadcastHandler) {
2    this.worker = new SharedWorker(url, { name, type: 'module' as any });
3    this.port = this.worker.port;
4    this.onBroadcast = onBroadcast;
  • In the constructor, it initializes the connection to the Shared Worker and sets up message listeners and heartbeat sending.
 1    this.port.addEventListener('message', (ev) => {
 2      const msg = ev.data as WorkerOutMessage;
 3      if (msg.kind === 'response') {
 4        const p = this.pending.get(msg.id);
 5        if (p) {
 6          this.pending.delete(msg.id);
 7          msg.ok ? p.resolve(msg.result) : p.reject(new Error(msg.error || 'error'));
 8        }
 9      } else if (msg.kind === 'broadcast') {
10        this.onBroadcast?.(msg);
11      }
12    });
13    this.port.start();
  • Here it receives messages from the worker and handles responses or broadcasts.
1    this.heartbeatTimer = window.setInterval(() => {
2      this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3    }, 10_000);
  • Sends heartbeat messages periodically to keep the connection alive.
1    window.addEventListener('beforeunload', () => {
2      try {
3        this.port.postMessage({ kind: 'bye', from: this.clientId });
4      } catch {}
5      if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
6    });
7  }
  • Sends a disconnect notification to the worker before the window closes.
1  request<T = unknown>(action: RequestAction): Promise<T> {
2    const id = randomId();
3    return new Promise<T>((resolve, reject) => {
4      this.pending.set(id, { resolve, reject });
5      this.port.postMessage({ kind: 'request', id, from: this.clientId, action });
6    });
7  }
  • The request method sends the specified action to the worker and receives the result as a Promise.
1  ping() {
2    return this.request<string>({ type: 'ping' });
3  }
4  echo(payload: string) {
5    return this.request<string>({ type: 'echo', payload });
6  }
7  getTime() {
8    return this.request<string>({ type: 'getTime' });
9  }
  • These are utility methods for basic communication tests and getting the current time.
1  set(key: string, value: unknown) {
2    return this.request<boolean>({ type: 'set', key, value });
3  }
4  get<T = unknown>(key: string) {
5    return this.request<T>({ type: 'get', key });
6  }
  • These are methods for storing and retrieving key-value pairs.
1  broadcast(channel: string, payload: unknown) {
2    return this.request<boolean>({ type: 'broadcast', channel, payload });
3  }
  • This is a method that sends broadcast messages to other clients via the worker.
1  lockAcquire(key: string, timeoutMs?: number) {
2    return this.request<{ owner: string }>({ type: 'lock.acquire', key, timeoutMs });
3  }
4  lockRelease(key: string) {
5    return this.request<boolean>({ type: 'lock.release', key });
6  }
7}
  • These are methods that acquire and release locks to achieve mutual exclusion over shared resources.
  • Across this file, a client API is implemented for safe, asynchronous communication from each browser tab to the Shared Worker.

Example usage

In demo.ts, we use the SharedWorkerClient class created earlier and verify its behavior. It sequentially executes a series of functions including communication tests, reading and writing data, broadcasting, and lock handling.

 1// demo.ts
 2import { SharedWorkerClient } from './shared-worker-client.js';
 3
 4const client = new SharedWorkerClient(new URL('./shared-worker.js', import.meta.url), 'app-shared', (b) => {
 5  console.log('[BROADCAST]', b.channel, JSON.stringify(b.payload));
 6});
 7
 8async function demo() {
 9  console.log('ping:', await client.ping());
10  console.log('echo:', await client.echo('hello'));
11  console.log('time:', await client.getTime());
12
13  // Counter update
14  await client.set('counter', (await client.get<number>('counter')) ?? 0);
15  const c1 = await client.get<number>('counter');
16  await client.set('counter', (c1 ?? 0) + 1);
17  console.log('counter:', await client.get<number>('counter'));
18
19  // Broadcast test
20  await client.broadcast('notify', { msg: 'Counter updated' });
21
22  // Lock test
23  const key = 'critical';
24  console.log(`[lock] Trying to acquire lock: "${key}"`);
25  const lockResult = await client.lockAcquire(key, 2000);
26  console.log(`[lock] Lock acquired for key "${key}":`, lockResult);
27
28  try {
29    console.log(`[lock] Simulating critical section for key "${key}"...`);
30    await new Promise((r) => setTimeout(r, 250));
31    console.log(`[lock] Critical section completed for key "${key}"`);
32  } finally {
33    console.log(`[lock] Releasing lock: "${key}"`);
34    const releaseResult = await client.lockRelease(key);
35    console.log(`[lock] Lock released for key "${key}":`, releaseResult);
36  }
37}
38
39demo().catch(console.error);
  • This code is a demo that uses a Shared Worker to share and synchronize data and state across multiple browser tabs. By using message-based communication, you can exchange asynchronous messages safely with loose coupling, making it easier to manage communication between different contexts. In addition, by using RPC, it abstracts communication with the worker in an intuitive, method-call-like style, improving maintainability and readability.

Testing in HTML

 1<!DOCTYPE html>
 2<html lang="en">
 3<head>
 4  <meta charset="UTF-8" />
 5  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 6  <title>Shared Worker Demo</title>
 7</head>
 8<body>
 9  <h1>SharedWorker Demo</h1>
10  <p>Open the browser console to see the output.</p>
11
12  <script type="module">
13    import './demo.js';
14  </script>
15</body>
16</html>

Design and operational considerations

When designing and operating, keeping the following points in mind will help you build a more robust and extensible system.

  • You can adopt a discriminated union (tagged union) that allows branching on kind or type.
  • Use a correlation ID to correctly match requests with responses.
  • Heartbeats and automatic cleanup can prevent abandoned locks.
  • Implement versioning to accommodate future protocol changes flexibly.
  • Defining clear error codes makes UI-side handling and debugging easier.

Summary

A Shared Worker is a core mechanism for sharing data and state across multiple browser tabs.

The structure introduced here provides type-safe RPC communication, liveness monitoring via heartbeats, and a locking mechanism, making it a robust design that can be used as-is in production.

On top of this mechanism, you can also implement the following applications.

  • Serializing IndexedDB access
  • Integration and sharing of WebSocket connections
  • Building a job queue across multiple tabs
  • Throttling and progress notification delivery

As you can see, leveraging a Shared Worker makes it possible to share data and processing safely and efficiently across multiple tabs.

You can follow along with the above article using Visual Studio Code on our YouTube channel. Please also check out the YouTube channel.

YouTube Video