`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 MessagePort
s 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 asping
,get
, andbroadcast
.
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 theShared 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 theShared 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 aShared 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 aPromise
.
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
ortype
. - 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.