Broadcast-channel picks its own transport at runtime, and its docs disagree on how many exist
:satellite: BroadcastChannel to send data between different browser-tabs or nodejs-processes :satellite: + LeaderElection over the channels https://pubkey.github.io/broadcast-channel/
At a glance
- What is it?
- One BroadcastChannel constructor covers browser tabs, iframes, WebWorkers, NodeJs processes and Deno by choosing native, IndexedDB, localstorage or filesystem sockets per runtime. The documented option list omits a transport the library tells you to use in tests.
- Who is it for?
- Take it when tabs, iframes, WebWorkers or NodeJs processes of one application must reach each other using nothing but a shared name, with no server in the path. Leave it alone when data has to cross machines, when you already run a bus, or when a channel must survive a user wiping site data on a phone.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Four transports picked per runtime behind one constructor
The package exports a single `BroadcastChannel` constructor and decides the underlying mechanism from the environment it lands in. Pass a `type` in the options object and the documented values are `native`, `idb`, `localstorage` and `node`. Native routes to the browser's own BroadcastChannel API and is what Deno runtimes get. The The idb transport serves browsers with WebWorkers that lack the native API, covering tabs, iframes and workers through a database. The `localstorage` entry catches older browsers with no IndexedDB, and `node` covers NodeJs.
Everything stays client-side and offline, which is the point for anyone wiring a single-page app whose tabs must agree without a backend. `package.json` marks the module `sideEffects` false, so a bundler is free to drop the import when no channel is constructed.
The plain alternative is the platform API the project links to with a caniuse reference, paired with Node's own inter-process mechanism. That path has no NodeJs fallback, no storage layer for browsers without the native API, and no leader election, which the project description lists as a feature layered over the channels.
A fifth transport the option list never mentions
For test suites the documentation tells you to enforce the fast `simulate` method globally. Both the methods table and the constructor's `type` field stay silent about it: the table names exactly four entries, Native, IndexedDB, LocalStorage and Sockets, and the option comment restricts `type` to `oneOf['native', 'idb', 'localstorage', 'node']`. Reading the code snippets alone, a maintainer would never guess `simulate` is a valid transport value. Passing it per constructor is undocumented; passing it through `enforceOptions` is the sanctioned route.
import { enforceOptions } from 'broadcast-channel';
// enforce this config for all channels
enforceOptions({
type: 'simulate'
});
// reset the enforcement
enforceOptions(null);Once enforced, every channel uses those options regardless of what the constructor received, which makes tests fast and makes an accidental mismatch with the constructor signature invisible until runtime. Resetting is a single call with `null`.
NodeJs channels ride filesystem sockets and leave folders behind
Under NodeJs the library talks to other processes over filesystem sockets, and every channel it opens corresponds to a directory in the tmp folder. Unit test suites that construct many channels can exhaust that folder, and the documented remedy is `BroadcastChannel.clearNodeFolder()`, also exported as `clearNodeFolder`. It returns true on NodeJs and false in browsers, which makes it safe to call from shared test setup.
import { clearNodeFolder } from 'broadcast-channel';
// jest
beforeAll(async () => {
const hasRun = await clearNodeFolder();
console.log(hasRun); // > true on NodeJs, false on Browsers
})A mocha `before` hook does the same job. Note what this cleanup is not: the README documents no way to inspect or cap the number of live channels, and no rollback path for a half-closed socket, so the tmp folder is the only surface you can manage.
Mobile Safari closes IndexedDB and polling stops with it
IndexedDB databases can vanish for reasons outside your code, for example when the underlying storage is removed or a user clears the database through history preferences, and the project reports this happening most often in Mobile Safari. The default reaction is to let the connection close and stop polling for changes. Delivery therefore dies silently in exactly the browsers where a backgrounded tab is normal.
Recovery means throwing the channel away and building another one, because no reconnect path is offered. The handler lives inside the idb sub-object of the options and calls `channel.close` before calling the factory again, so an unguarded handler can recurse if you forget that first close.
import { BroadcastChannel } from 'broadcast-channel';
let channel;
const createChannel = () => {
channel = new BroadcastChannel(CHANNEL_NAME, {
idb: {
onclose: () => {
// the onclose event is just the IndexedDB closing.
// you should also close the channel before creating
// a new one.
channel.close();
createChannel();
},
},
});
channel.onmessage = message => {
// handle message
};
};Installing and sending the first message from two contexts
The install command is a single npm call, and the library ships prebuilt bundles rather than asking you to compile anything.
npm install --save broadcast-channelSend from one tab or process by constructing a channel with a name and calling `postMessage`. Nothing is delivered to the sender itself.
import { BroadcastChannel } from 'broadcast-channel';
const channel = new BroadcastChannel('foobar');
channel.postMessage('I am not alone');Open a second tab or process under the same name and assign `onmessage`. The documentation shows the console receiving the string sent above.
import { BroadcastChannel } from 'broadcast-channel';
const channel = new BroadcastChannel('foobar');
channel.onmessage = msg => console.dir(msg);
// > 'I am not alone'For several handlers, pair `addEventListener` with `removeEventListener` on the same function reference, and shut the channel down with `await channel.close()`, a call that resolves once everything queued has been processed. Skipping that await is how messages get dropped at shutdown.
package.json routes node, deno and browser to different builds
The export map is where the runtime split becomes concrete. For `node`, `import` resolves to `./dist/esnode/index.js` and the fallback condition to `./dist/es5node/index.js`, with types at `./types/index.d.ts`. For `deno`, `import` resolves to `./dist/esbrowser/index.js` while the fallback points to `./dist/lib/index.js`, a different file from the one the browser fallback uses. For `browser`, the fallback is `./dist/lib/index.es5.js`, and the top level `default` condition lands on that same es5 build. So a Deno resolver without import conditions receives a file no other condition names.
The legacy fields mirror the same layout: `main` is `./dist/es5node/index.js`, `module` is `./dist/esnode/index.js` and `browser` is `./dist/lib/index.es5.js`. Consumers resolving with an older toolchain and consumers using conditional exports can therefore land on different builds of the same number, which matters when `package.json` already says 7.4.0 while the newest published release is 7.3.0, cut on 2026-01-20. The last push was on 2026-10-02. The tree also commits `dist/`, `types/`, `config/`, `docs/`, `test/`, `test-electron/` and a `perf.txt`.
The options snippet in the docs is not valid JavaScript
The constructor example ends with `webWorkerSupport: true;`, a semicolon inside an object literal that a JavaScript parser rejects if you paste it as given. TypeScript tolerates the stray separator, which hides the problem until someone copies the block into a .js file. The comment beside it is also worth reading twice: setting it to false is only safe when no WebWorker will ever use the channel, and the claimed gain is stated as increased performance without a figure.
const options = {
type: 'localstorage', // (optional) enforce a type, oneOf['native', 'idb', 'localstorage', 'node']
webWorkerSupport: true; // (optional) set this to false if you know that your channel will never be used in a WebWorker (increases performance)
};
const channel = new BroadcastChannel('foobar', options);Typing is handled through the generic parameter, which is the one part of the surface with an explicit compile-time contract.
import { BroadcastChannel } from 'broadcast-channel';
declare type Message = {
foo: string;
};
const channel: BroadcastChannel<Message> = new BroadcastChannel('foobar');
channel.postMessage({
foo: 'bar'
});Note that the generic constrains one end of the channel only. Nothing in the documentation states that a message shape mismatch between two tabs is caught anywhere, so pairing the same type across your codebase is manual work.
Editorial conclusion
Take it when tabs, iframes, WebWorkers or NodeJs processes of one application must reach each other using nothing but a shared name, with no server in the path. Leave it alone when data has to cross machines, when you already run a bus, or when a channel must survive a user wiping site data on a phone. Verify two things first in your own codebase: that your bundler resolves the condition you expect, since Deno and browser fallbacks point at different files, and that the onclose handler recreates the channel without recursion. In test suites, call `clearNodeFolder` in the setup hook so filesystem sockets do not fill the tmp directory.
Frequently asked questions
How do I send messages between two NodeJs processes with broadcast-channel?
In NodeJs the library reaches other processes over filesystem sockets, so both sides construct a BroadcastChannel with the same name and communicate without a server. It also works in Deno runtimes through the native BroadcastChannel API.
Why does broadcast-channel stop receiving messages in Mobile Safari?
IndexedDB can close unexpectedly when storage is removed or a user clears the database in history preferences, and the project reports this most often in Mobile Safari. By default the connection closes and polling stops, so you have to close the channel and create a new one from the onclose handler.
How do I make broadcast-channel fast in a test suite?
Call `enforceOptions` once with `type: 'simulate'`, which the documentation recommends for test suites, and every channel then uses those options whatever the constructor was given. Pass `null` to `enforceOptions` to lift the enforcement.
Does broadcast-channel need a server or an internet connection?
No. Everything runs client-side and offline, across browser tabs, iframes, WebWorkers, NodeJs processes and Deno instances, which is what makes it usable for offline-first applications.
What happens if I do not close a broadcast-channel before my process exits?
The documentation gives no guidance on that case, so it is not covered. It does document that `close()` returns a Promise resolved once everything is processed, and that NodeJs channels create tmp directories that can pile up in unit tests.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/pubkey-broadcast-channel)Was this useful?