Skip to content

Cross-Context Communication

advanced18 min read

The Routing Problem

You have three workers: one parsing CSV data, one running statistical analysis, one generating a chart. The analysis worker needs data from the parser. The chart worker needs results from the analysis worker. How do you wire this up?

// The naive approach: route everything through the main thread
const parser = new Worker('/workers/parser.js');
const analyzer = new Worker('/workers/analyzer.js');
const charter = new Worker('/workers/charter.js');

parser.onmessage = (e) => {
  if (e.data.type === 'PARSED') {
    analyzer.postMessage(e.data.records); // Main thread as relay
  }
};

analyzer.onmessage = (e) => {
  if (e.data.type === 'ANALYZED') {
    charter.postMessage(e.data.stats); // Main thread as relay again
  }
};

Every message passes through the main thread — serialized, deserialized, re-serialized, re-deserialized. The main thread is a bottleneck relay station doing zero useful work. For a 10MB dataset, you're paying the structured clone cost four times instead of two.

There's a better way. MessageChannel lets workers talk directly to each other, bypassing the main thread entirely.

Mental Model

Think of the main thread as an office receptionist. In the naive approach, every memo between departments goes through the receptionist: Department A hands it to reception, reception walks it to Department B. With MessageChannel, you install a direct phone line between departments. They talk directly — the receptionist doesn't even know the conversation is happening. BroadcastChannel is the office PA system — everyone hears the announcement. SharedWorker is a shared assistant that multiple offices (browser tabs) can all talk to.

MessageChannel: Direct Worker-to-Worker Pipes

MessageChannel creates a pair of entangled MessagePort objects. Send on one, receive on the other. The critical insight: MessagePort is transferable, so you can send one port to a worker and the other port to a different worker — establishing a direct communication link:

// main.js — set up the pipe, then step out of the way
const parser = new Worker('/workers/parser.js', { type: 'module' });
const analyzer = new Worker('/workers/analyzer.js', { type: 'module' });

const channel = new MessageChannel();

// Transfer port1 to parser, port2 to analyzer
parser.postMessage({ type: 'CONNECT', port: channel.port1 }, [channel.port1]);
analyzer.postMessage({ type: 'CONNECT', port: channel.port2 }, [channel.port2]);

// Now parser and analyzer can talk directly — main thread is not involved
// parser.js
let analyzerPort = null;

self.onmessage = (event) => {
  if (event.data.type === 'CONNECT') {
    analyzerPort = event.data.port;
    return;
  }
  if (event.data.type === 'PARSE') {
    const records = parseCSV(event.data.csv);
    analyzerPort.postMessage(records); // Direct to analyzer — skips main thread
  }
};
// analyzer.js
let dataPort = null;

self.onmessage = (event) => {
  if (event.data.type === 'CONNECT') {
    dataPort = event.data.port;
    dataPort.onmessage = (msg) => {
      const stats = analyze(msg.data);
      self.postMessage({ type: 'RESULTS', stats });
    };
    return;
  }
};
Quiz
What is the performance advantage of using MessageChannel for worker-to-worker communication instead of routing through the main thread?

Building a Worker Pipeline

With MessageChannel, you can build multi-stage processing pipelines where each stage is a separate worker:

// main.js — wire up a three-stage pipeline
function createPipeline(stages) {
  const workers = stages.map(
    (url) => new Worker(url, { type: 'module' })
  );

  for (let i = 0; i < workers.length - 1; i++) {
    const channel = new MessageChannel();
    workers[i].postMessage(
      { type: 'SET_OUTPUT', port: channel.port1 },
      [channel.port1]
    );
    workers[i + 1].postMessage(
      { type: 'SET_INPUT', port: channel.port2 },
      [channel.port2]
    );
  }

  return {
    input: workers[0],
    output: workers[workers.length - 1],
    terminate: () => workers.forEach((w) => w.terminate()),
  };
}

const pipeline = createPipeline([
  '/workers/parse.js',
  '/workers/transform.js',
  '/workers/aggregate.js',
]);

pipeline.output.onmessage = (e) => {
  renderResults(e.data);
};

pipeline.input.postMessage({ csv: rawData });

Each worker in the pipeline only knows about its input port and output port. The main thread sets up the wiring once and then only interacts with the first and last stages.

Quiz
In a three-worker pipeline using MessageChannel, how many structured clone operations happen for a single message flowing from stage 1 to stage 3?

BroadcastChannel: One-to-Many Messaging

BroadcastChannel is a publish-subscribe system scoped to a channel name. Every context (tab, worker, iframe) that creates a BroadcastChannel with the same name receives every message:

// In any context — main thread, worker, another tab
const channel = new BroadcastChannel('app-state');

// Send to all subscribers
channel.postMessage({ type: 'THEME_CHANGED', theme: 'dark' });

// Receive from any sender
channel.onmessage = (event) => {
  if (event.data.type === 'THEME_CHANGED') {
    applyTheme(event.data.theme);
  }
};

// Stop receiving
channel.close();

Key differences from MessageChannel:

FeatureMessageChannelBroadcastChannel
TopologyPoint-to-point (1:1)Broadcast (1:N)
TransferablesSupportedNOT supported
Cross-tabNo (unless port is transferred)Yes (same origin)
Sender receives own messageNoNo
Message orderingFIFO guaranteedFIFO per sender
Common Trap

BroadcastChannel does NOT support transferable objects. Every message is structured-cloned to every subscriber. If you have 5 tabs listening and you broadcast a 1MB object, the browser clones it 5 times. Use BroadcastChannel for small coordination messages (state changes, notifications), not for bulk data transfer.

Quiz
You need to synchronize a theme change across all open tabs of your application. Which communication mechanism is most appropriate?

SharedWorker: One Worker, Multiple Clients

A SharedWorker is a single worker instance shared across all same-origin tabs, iframes, and other contexts. Unlike a dedicated worker (one per creator), a SharedWorker maintains state that is accessible from any connected context:

// Any tab can connect to the same SharedWorker
const shared = new SharedWorker('/workers/shared-state.js', { type: 'module' });

shared.port.onmessage = (event) => {
  console.log('Received:', event.data);
};

shared.port.postMessage({ type: 'GET_STATE' });
// shared-state.js — the SharedWorker
const connections = new Set();
let sharedState = { count: 0 };

self.onconnect = (event) => {
  const port = event.ports[0];
  connections.add(port);

  port.onmessage = (msg) => {
    if (msg.data.type === 'GET_STATE') {
      port.postMessage(sharedState);
    }
    if (msg.data.type === 'INCREMENT') {
      sharedState.count++;
      for (const conn of connections) {
        conn.postMessage(sharedState);
      }
    }
  };

  port.start();
};

SharedWorker use cases:

  • WebSocket connection sharing — one WebSocket across all tabs instead of one per tab
  • Cache coordination — shared in-memory cache so each tab doesn't fetch independently
  • Cross-tab state — synchronized state without localStorage polling
  • Connection pooling — shared database or API connections
SharedWorker Browser Support

SharedWorker is supported in Chrome, Firefox, and Edge, but NOT in Safari as of 2025. Safari removed SharedWorker support years ago and has not re-added it. If you need cross-tab communication in Safari, use BroadcastChannel (which Safari does support) or fall back to localStorage events.

Quiz
What is the main architectural advantage of SharedWorker over using BroadcastChannel for cross-tab communication?

Service Worker Messaging

Service Workers intercept network requests, but they can also serve as a message relay between the main thread and other contexts:

// Main thread to Service Worker
navigator.serviceWorker.controller?.postMessage({
  type: 'CACHE_UPDATE',
  urls: ['/api/data'],
});

// Service Worker: listen for messages
self.addEventListener('message', (event) => {
  if (event.data.type === 'CACHE_UPDATE') {
    event.waitUntil(updateCache(event.data.urls));
  }
});

// Service Worker to ALL clients (tabs)
self.addEventListener('message', (event) => {
  if (event.data.type === 'BROADCAST') {
    self.clients.matchAll().then((clients) => {
      for (const client of clients) {
        client.postMessage(event.data.payload);
      }
    });
  }
});

Service Workers are powerful for cross-tab messaging because they have access to self.clients — a list of all controlled pages. But they have a different lifecycle (install/activate/fetch) and are not suitable for CPU-intensive work.

Choosing the Right Channel

Need direct worker-to-worker communication?     → MessageChannel
Need to broadcast to all tabs/workers?           → BroadcastChannel
Need shared state across tabs?                   → SharedWorker (or BroadcastChannel + localStorage for Safari)
Need to coordinate cache/network across tabs?    → Service Worker messaging
Need bidirectional communication with one worker? → postMessage (simplest)
What developers doWhat they should do
Using the main thread as a relay between workers
Relaying through the main thread doubles the structured clone cost and adds latency from main thread task scheduling. Direct MessageChannel communication is faster and keeps the main thread free.
Create a MessageChannel and transfer one port to each worker for direct communication
Using BroadcastChannel to send large data payloads
BroadcastChannel clones the message to every subscriber. Sending 1MB to 5 listeners costs 5 structured clones. It also does not support transferable objects.
Use BroadcastChannel for small coordination messages only. For large data, use MessageChannel with transferables
Assuming SharedWorker works in Safari
Safari does not support SharedWorker. If your application needs cross-tab state in Safari, use BroadcastChannel for messaging and IndexedDB or localStorage for shared state.
Check browser support and provide a BroadcastChannel or localStorage fallback for Safari
Not calling port.start() on SharedWorker connections when using addEventListener
When you use port.onmessage, the port auto-starts. But when you use port.addEventListener('message', ...), you must explicitly call port.start() or no messages will be delivered. This is a spec quirk that causes silent failures.
Always call port.start() when using addEventListener for the message event on SharedWorker ports

Challenge: Cross-Tab Notification System

Challenge: Multi-Tab Notifications

Try to solve it before peeking at the answer.

javascript
// Build a notification system where:
// 1. Any tab can send a notification
// 2. All OTHER tabs display the notification (sender does not)
// 3. Notifications include a unique ID, message, and timestamp
// 4. Each tab tracks which notifications it has seen (no duplicates)
// 5. Works in Safari (no SharedWorker)
//
// API:
// notify('New comment on your post')  — sends to all other tabs
// onNotification(callback)            — registers a listener
//
// Hint: BroadcastChannel + tab ID for sender filtering

Key Rules

Key Rules
  1. 1MessageChannel creates a direct pipe between two contexts. Transfer one port to each worker for zero-relay communication that bypasses the main thread.
  2. 2BroadcastChannel delivers messages to all same-origin contexts. It does NOT support transferables — use it for small coordination messages only.
  3. 3SharedWorker is a single worker shared across tabs. It can hold persistent state but is NOT supported in Safari.
  4. 4MessagePort must be explicitly started via port.start() when using addEventListener. The port.onmessage setter auto-starts it.
  5. 5Choose the simplest channel that fits: postMessage for single worker, MessageChannel for worker-to-worker, BroadcastChannel for cross-tab broadcasts.