broadcast-channel review
broadcast-channel moves JSON-serializable messages among tabs, frames, workers, or local Node processes that share a channel name. It chooses the native browser API when available, then IndexedDB or localStorage; Node communication goes through filesystem sockets. Its second job is leader election, which lets one tab own a WebSocket or polling loop. Version 7.4.0 fixes a race where an elector could become leader after die() and makes repeated die() calls return the same promise. It also exports the OnMessageHandler TypeScript type. Our package check found bundled declarations and working require() and ESM import paths.
broadcast-channel 7.4.0 installed in 1.4 seconds and produced a 5.3 KB gzipped browser bundle in our sandbox, a fair cost when fallback transports or leader election are required. Use the native browser API for simple notifications, and use a broker when messages must cross machines or survive disconnects.
We installed it
| Install | ✓ · 1.4s | 8 packages on disk · 3 MB |
| Import | ✓ | ESM import works · require() works · CommonJS package with exports map |
| Browser | 5.3 KB | gzipped (16.5 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does broadcast-channel install cleanly?
Yes. In a fresh container with an empty cache, npm install broadcast-channel finished in 1 seconds, leaving 8 packages and 3 MB on disk. npm audit reported no known vulnerabilities.
How much does broadcast-channel add to a browser bundle?
5.3 KB gzipped (16.5 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does broadcast-channel work with both ESM and CommonJS?
Yes. Both import 'broadcast-channel' and require('broadcast-channel') worked in Node 22 in our run. The package is published as CommonJS with an exports map.
Does broadcast-channel include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
broadcast-channel or @vueuse/core: which should you use?
@vueuse/core: Choose its useBroadcastChannel helper when the application is Vue-only and native browser support is sufficient. broadcast-channel 7.4.0 installed in 1.4 seconds and produced a 5.3 KB gzipped browser bundle in our sandbox, a fair cost when fallback transports or leader election are required.
When should you not use broadcast-channel?
All supported browsers already have native BroadcastChannel and leader election is unnecessary; the platform API avoids four direct dependencies
Use it if
- Old and current browsers must exchange tab or worker notifications through one API
- One browser tab should own a shared WebSocket, polling loop, or scheduled refresh
- Local Node processes need low-rate coordination without a Redis service
- Tests need a deterministic in-process transport that keeps the production channel API
- All supported browsers already have native BroadcastChannel and leader election is unnecessary; the platform API avoids four direct dependencies
- Messages need acknowledgement, replay, or storage for late subscribers; the README says this package is not a queue and version 7 ignores messages sent before channel creation
- Payloads contain BigInt, cycles, functions, or binary objects; this implementation accepts values handled by JSON.stringify rather than the native structured-clone range
- Communication crosses origins, machines, or devices; browser storage boundaries and Node filesystem sockets keep every built-in transport local
- A Node or Deno process will send more than 50 messages per second; the maintainer directs that workload to dedicated IPC tooling
- Duplicate leadership could trigger an irreversible operation; the README documents duplicate leaders under CPU saturation or throttled browser timers
Setup reality
Our clean install of broadcast-channel 7.4.0 finished in 1.4 seconds on Node 22. It left 8 packages and 3 MB on disk, while npm audit reported 0 known vulnerabilities. The package itself is 836 KB unpacked with 4 direct dependencies and no peers. It is published as CommonJS with an exports map; require() and ESM import both worked, and TypeScript declarations are included. Our full browser import measured 16.5 KB minified and 5.3 KB gzipped.
No account, secret, or config file is involved. Transport choice is the first surprise: current browsers normally use native BroadcastChannel, with IndexedDB and localStorage as fallbacks. Setting webWorkerSupport to false removes IndexedDB from consideration, which can help a tab-only case but excludes workers. Mobile Safari and cleared site storage can close IndexedDB unexpectedly. The documented recovery is to close that channel and construct another from the idb.onclose callback.
Messages have no replay and the sender does not receive its own post. Values must survive JSON.stringify, and a listener gets the value directly rather than event.data. Await close() so pending sends finish and storage or socket resources are released. In Node, the filesystem transport uses temporary directories. The defaults include a 2-minute TTL and a 2,048-write parallel cap; large test suites should call clearNodeFolder() before opening their channels, never while production peers are active.
Leader election uses Web Locks where the runtime supplies them and falls back to channel messages elsewhere. Version 7.4.0 prevents an elector killed during an in-flight election from later claiming leadership, and die() is now idempotent. Duplicate leaders can still occur when a browser throttles timers or the CPU is saturated, so make elected work safe to repeat and implement onduplicate. The simulate transport is fast for tests but cannot communicate outside its one JavaScript process; enforceOptions() is global and needs resetting after each suite.
Patterns
Exchange a typed session event send-typed-message
import { BroadcastChannel } from 'broadcast-channel'
type SessionEvent =
| { type: 'signed-out' }
| { type: 'profile-updated'; userId: string }
const channel = new BroadcastChannel<SessionEvent>('app-session')
channel.onmessage = (event) => {
if (event.type === 'signed-out') redirectToLogin()
}
await channel.postMessage({ type: 'profile-updated', userId: 'u_42' })Version 7.4.0 passes the posted value to onmessage, so event.data is undefined here. The runtime payload must also survive JSON.stringify.
Attach two independent consumers manage-listeners
const channel = new BroadcastChannel('inventory')
const refresh = (message) => updateInventory(message)
const record = (message) => console.debug(message)
channel.addEventListener('message', refresh)
channel.addEventListener('message', record)
channel.removeEventListener('message', record)addEventListener keeps both callbacks active. Assigning onmessage a second time replaces the first onmessage callback.
Finish pending sends during shutdown close-channel
const channel = new BroadcastChannel('jobs')
try {
await channel.postMessage({ type: 'job-finished', id: job.id })
} finally {
await channel.close()
}close() returns a promise that settles after queued sends and transport cleanup. A later postMessage() throws.
Report the selected transport inspect-transport
const channel = new BroadcastChannel('diagnostics')
console.log({
name: channel.name,
transport: channel.type,
closed: channel.isClosed,
})The 5 built-in transport names are native, idb, localstorage, node, and simulate. Runtime support and options decide the result.
Reopen after IndexedDB closes recover-indexeddb
let channel
function openChannel() {
channel = new BroadcastChannel('sync', {
idb: {
onclose: async () => {
await channel.close()
openChannel()
},
},
})
channel.onmessage = handleSync
}
openChannel()Mobile Safari and cleared site storage can close IndexedDB. The README calls for closing the wrapper before creating its replacement.
Limit an old-browser channel to tabs disable-worker-support
const channel = new BroadcastChannel('tab-events', {
webWorkerSupport: false,
})
channel.onmessage = handleEventWith webWorkerSupport false, method selection excludes IndexedDB. Workers cannot participate, and an old browser may fall back to localStorage.
Use the single-process test method simulate-in-tests
import { enforceOptions } from 'broadcast-channel'
beforeEach(() => enforceOptions({ type: 'simulate' }))
afterEach(() => enforceOptions(null))enforceOptions changes every channel in the process. Reset it after each test because simulate cannot reach a second JavaScript process.
Remove stale Node test sockets clear-node-folders
import { clearNodeFolder } from 'broadcast-channel'
beforeAll(async () => {
const cleared = await clearNodeFolder()
console.log({ cleared })
})clearNodeFolder() reports true under Node and false in browsers. Run it before an isolated suite, not while other processes use those channels.
Give one tab the shared connection elect-tab-leader
import { BroadcastChannel, createLeaderElection } from 'broadcast-channel'
const channel = new BroadcastChannel('server-connection')
const elector = createLeaderElection(channel)
await elector.awaitLeadership()
const connection = connectToServer()awaitLeadership() starts the election lazily. The work after it should be idempotent because the README allows rare duplicate leaders.
Wait longer for throttled peers tune-election
const elector = createLeaderElection(channel, {
responseTime: 1000,
fallbackInterval: 2000,
})
await elector.awaitLeadership()A 1,000 ms response window can reduce duplicate elections on slow tabs, while the 2,000 ms fallback interval also delays failover.
Stop work after duplicate leadership handle-duplicate-leaders
const elector = createLeaderElection(channel)
elector.onduplicate = async () => {
stopExclusiveWork()
await elector.die()
reportDuplicateLeader()
}
await elector.awaitLeadership()
startIdempotentWork()onduplicate detects a conflict after it happens. Keep irreversible operations behind a server-side lock or idempotency check.
Retire an elector once release-leadership
if (elector.isLeader) {
await stopLeaderOnlyResources()
}
await elector.die()
await channel.close()Version 7.4.0 makes repeated die() calls share one shutdown promise. An elector that has died cannot compete again.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @vueuse/core | npm | Choose its useBroadcastChannel helper when the application is Vue-only and native browser support is sufficient |
| redux-state-sync | npm | Choose it when the concrete requirement is copying selected Redux actions between tabs |
| redis | npm | Choose Redis pub/sub when Node publishers span machines or exceed local filesystem IPC |
| ws | npm | Choose WebSockets when a server must connect browsers across origins or devices |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

