broadcast-channel
broadcast-channel sends small messages between browsing contexts or Node processes that open the same named channel. It prefers the native BroadcastChannel API, then falls back to IndexedDB or localStorage in browsers; Node uses filesystem sockets, and tests can select an in-process simulator. It also includes leader election, using Web Locks when available and a message-based algorithm otherwise, so one tab can own work such as a server connection. The API resembles the browser standard but is not a polyfill: payloads must be JSON-serializable and handlers receive the posted data directly, not a MessageEvent.
The strongest install when cross-tab fallbacks and leader election are both real requirements. Prefer native BroadcastChannel for modern-browser notifications, and move to Redis, WebSockets, or another broker when delivery, trust, throughput, or machine boundaries matter.
Use it if
- Several same-origin tabs, frames, or workers need low-latency notifications and you must support browsers without native BroadcastChannel
- A few Node processes on one machine need light coordination without operating Redis, a broker, or a network service
- You need one active tab to own a WebSocket, polling loop, or scheduled task and can tolerate leader re-election
- You want the same typed channel API in browsers, Node, and Deno, plus a fast deterministic transport for tests
- Modern-browser support is enough and you do not need leader election; the native BroadcastChannel API avoids four runtime dependencies and fallback storage code
- You need durable delivery, history, acknowledgements, or late-subscriber replay; version 7 deliberately ignores messages created before a channel listener and the project says it is not a message queue
- You send binary, cyclic, BigInt, function, or other non-JSON data; the README limits payloads to values that JSON.stringify can handle, unlike the native structured-clone API
- You need cross-origin, cross-device, or multi-host communication; browser transports stay inside their storage/origin boundary and Node transport uses local filesystem sockets
- Your Node or Deno workload exceeds 50 messages per second; the README explicitly directs that traffic to proper IPC tooling
- Exactly one leader is a safety requirement for billing, migrations, or irreversible writes; the project documents rare duplicate leaders during CPU saturation and browser timer throttling
- Messages contain secrets or require authenticated senders; the package provides local transport selection, not encryption, authorization, sender identity, or an audit log
Setup reality
npm install broadcast-channel is enough and version 7.4.0 ships conditional browser, Node, and Deno entry points plus TypeScript declarations. There are no native builds, credentials, or required config files, but runtime behavior depends heavily on the selected transport. Modern browsers use native BroadcastChannel; older environments fall back to IndexedDB, then localStorage. Setting webWorkerSupport to false removes IndexedDB from selection and can improve performance, but it also means workers cannot participate. IndexedDB can close when storage is cleared or on Mobile Safari; supply idb.onclose, close the dead channel, and create a fresh instance. Payloads must be JSON-serializable, listeners receive the data itself rather than event.data, a sender should not expect its own message back, and channels do not replay history. Always await close(), especially in tests, because it waits for pending sends and releases transport resources. Node uses filesystem sockets and temporary folders with a two-minute default TTL and a 2,048-write default parallel cap. Large test suites can accumulate folders, so call clearNodeFolder() at suite start; production code should not casually delete coordination state while other processes are live. The simulate method is much faster in tests but stays inside one JavaScript process. enforceOptions() is global, overrides constructor options for every channel, and must be reset after each suite to avoid contaminating unrelated tests. Leader election starts lazily when awaitLeadership() is called, uses Web Locks where available, and remains best-effort: implement onduplicate and make leader work idempotent. None of the transports turns this into cross-host messaging or a durable queue.
Patterns
Send and receive typed messagessend-typed-message
import { BroadcastChannel } from 'broadcast-channel'
type AppMessage =
| { type: 'signed-out' }
| { type: 'profile-updated'; userId: string }
const channel = new BroadcastChannel<AppMessage>('app-session')
channel.onmessage = (message) => {
if (message.type === 'signed-out') redirectToLogin()
}
await channel.postMessage({ type: 'profile-updated', userId: 'u_42' })The handler receives the posted value directly, not a MessageEvent. Payloads must be JSON-serializable even when TypeScript accepts a broader shape.
Add and remove independent listenersmanage-multiple-listeners
import { BroadcastChannel } from 'broadcast-channel'
const channel = new BroadcastChannel('inventory')
const refresh = (message) => updateInventory(message)
const audit = (message) => console.debug(message)
channel.addEventListener('message', refresh)
channel.addEventListener('message', audit)
channel.removeEventListener('message', audit)Assigning onmessage again replaces the previous onmessage handler. addEventListener is the right API when several consumers coexist.
Wait for pending sends before shutdownclose-channel
const channel = new BroadcastChannel('jobs')
try {
await channel.postMessage({ type: 'job-finished', id: job.id })
} finally {
await channel.close()
}
console.log(channel.isClosed)close() waits for in-flight sends and transport cleanup. postMessage() after closure throws and includes the serialized message in its error text.
See which transport was selectedinspect-selected-transport
const channel = new BroadcastChannel('diagnostics')
console.log({
channel: channel.name,
transport: channel.type,
closed: channel.isClosed,
})Typical values are native, idb, localstorage, node, or simulate. Selection varies by runtime and constructor options.
Recreate a channel after IndexedDB closesrecover-indexeddb-close
let channel
function openChannel() {
channel = new BroadcastChannel('sync', {
idb: {
onclose: async () => {
await channel.close()
openChannel()
},
},
})
channel.onmessage = handleSync
}
openChannel()Unexpected IndexedDB closure is seen most often on Mobile Safari or after users clear storage. Close the old wrapper before opening its replacement.
Skip IndexedDB when workers are irrelevantdisable-worker-support
const channel = new BroadcastChannel('tab-only-events', {
webWorkerSupport: false,
})
channel.onmessage = handleEventThis can reduce overhead, but method selection removes IndexedDB and may fall back to localStorage when native BroadcastChannel is unavailable. Workers then cannot join.
Force the fast in-process test transportsimulate-in-tests
import { enforceOptions } from 'broadcast-channel'
beforeEach(() => {
enforceOptions({ type: 'simulate' })
})
afterEach(() => {
enforceOptions(null)
})enforceOptions is global and overrides every constructor option. The simulate transport communicates only inside the current JavaScript process.
Clear Node transport folders before testsclean-node-test-folders
import { clearNodeFolder } from 'broadcast-channel'
beforeAll(async () => {
const cleared = await clearNodeFolder()
console.log({ cleared })
})The promise returns true on the Node transport and false in browsers. Use this for isolated test setup, not while live production processes share channels.
Run one task in the elected tabelect-tab-leader
import { BroadcastChannel, createLeaderElection } from 'broadcast-channel'
const channel = new BroadcastChannel('server-connection')
const elector = createLeaderElection(channel)
await elector.awaitLeadership()
const connection = connectToServer()Election is lazy and starts when awaitLeadership() is called. Closing a tab normally releases leadership, but leader work should still be idempotent.
Allow more time for throttled peerstune-leader-election
const elector = createLeaderElection(channel, {
responseTime: 1000,
fallbackInterval: 2000,
})
await elector.awaitLeadership()Increase responseTime when multiple leaders appear under slow or timer-throttled environments. Larger values also make failover slower.
Detect and contain duplicate leadershandle-duplicate-leaders
const elector = createLeaderElection(channel)
elector.onduplicate = async () => {
stopExclusiveWork()
await elector.die()
reportDuplicateLeader()
}
await elector.awaitLeadership()
startIdempotentWork()The README documents rare duplicate leaders during CPU saturation and browser timer throttling. Detection is not a substitute for server-side idempotency or locking.
Give up leadership cleanlyrelease-leadership
if (elector.isLeader) {
await stopLeaderOnlyResources()
}
await elector.die()
await channel.close()After die(), pending calls to awaitLeadership() on that elector will no longer resolve. Create a new elector if this process may compete again later.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @vueuse/core | npm | A Vue application only needs a reactive wrapper around the browser's native BroadcastChannel API |
| redux-state-sync | npm | Your actual task is synchronizing selected Redux actions and state across browser tabs |
| redis | npm | Node processes run on multiple hosts or need higher-rate pub/sub through an operated server |
| ws | npm | Browsers or devices need server-mediated real-time messages beyond one origin and one machine |