npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tabs-broadcast

v4.2.1

Published

Tiny zero-dependency TypeScript library for inter-tab communication over the BroadcastChannel API, with primary-tab (leader) election via Web Locks.

Downloads

665

Readme

TabsBroadcast

npm version npm downloads types minzipped size License

TabsBroadcast is a tiny, zero-dependency TypeScript library for inter-tab communication over the BroadcastChannel API. It elects a single primary tab (leader) — via the Web Locks API with a localStorage fallback — so exactly one tab performs work that must not run in duplicate, while every tab can publish and subscribe to typed events, optionally isolated into layers.

🔎 Live demo: tabs-broadcast.ravy.pro

Table of contents

Features

  • Inter-tab communication over the BroadcastChannel API.
  • Primary/slave (leader) election with automatic failover when the primary tab closes.
  • Web Locks-based leadership (race-free, self-healing) with a localStorage heartbeat fallback.
  • Layers to isolate event streams (great for micro-frontends).
  • Typed events via an optional generic — full autocomplete and payload type-checking.
  • Wildcard (*) listeners, one-time listeners, and bulk registration.
  • Extensible through plugins.
  • Zero dependencies, ESM + UMD builds, first-class TypeScript types.

Installation

npm install tabs-broadcast
# or: pnpm add tabs-broadcast / yarn add tabs-broadcast / bun add tabs-broadcast

Quick start

import TabsBroadcast from 'tabs-broadcast';
// or: import { TabsBroadcast } from 'tabs-broadcast';

const bus = new TabsBroadcast();

// Subscribe (in every tab)
bus.on('cart:update', ({ payload }) => {
  console.log('cart changed:', payload);
});

// Publish (only the primary tab emits by default)
if (bus.primary) {
  bus.emit('cart:update', { items: 3 });
}

TabsBroadcast is a singleton: constructing it again returns the existing instance. Call destroy() to tear it down.

Configuration

Pass options to the constructor (or setConfig):

| Option | Type | Default | Description | | --- | --- | --- | --- | | channelName | string | xploit_tab_channel | BroadcastChannel name. Use the same value across micro-frontends to make them talk; use distinct values for fully independent instances. | | listenOwnChannel | boolean | false | If true, the emitting tab also receives its own messages locally. | | emitByPrimaryOnly | boolean | true | If true, emit() is a no-op on non-primary tabs. | | onBecomePrimary | (detail: { tabId: string }) => void | () => {} | Called when this tab acquires primary status. | | disableInternalErrors | boolean | true | If false, internal errors are logged via console.error. |

const bus = new TabsBroadcast({
  channelName: 'my_app',
  emitByPrimaryOnly: true,
  onBecomePrimary: ({ tabId }) => console.log('I am primary now:', tabId),
});

To make a channel harder for unrelated same-origin scripts to target, use a non-default, hard-to-guess channelName (see Security considerations).

Primary / slave tabs

The library guarantees that exactly one open tab is the primary; the rest are slaves. When the primary tab is closed or refreshed, another tab is promoted automatically. This is useful to:

  • Avoid conflicts — run server sync / writes in one tab only.
  • Save resources — perform background polling once instead of per-tab.
  • Centralize state & notifications — one tab coordinates and de-duplicates alerts.
const bus = new TabsBroadcast({ onBecomePrimary: () => startBackgroundSync() });

bus.on('data:sync', ({ payload }) => updateLocalCache(payload));

if (bus.primary) {
  // only the primary tab reaches the server
  bus.emit('data:sync', await fetchLatest());
}

Layers

Layers split events within one channel into independent streams. A listener registered in a layer only receives events emitted to that layer. Layers also improve performance (fewer iterations) and memory use, and are ideal for isolating micro-frontends sharing one channel.

bus.on('update', onCheckout, 'CHECKOUT');
bus.on('update', onCatalog, 'CATALOG');

bus.emit('update', { id: 1 }, 'CHECKOUT');            // only onCheckout fires
bus.emit('update', { id: 2 }, ['CHECKOUT', 'CATALOG']); // both fire

Typed events

Provide an event map as a generic argument to get full type-safety on on/once/emit:

type Events = {
  'cart:update': { items: number };
  'user:logout': null;
  ping: number;
};

const bus = new TabsBroadcast<Events>();

bus.on('cart:update', ({ payload }) => payload.items.toFixed()); // payload: { items: number }
bus.emit('ping', 42);                                            // ✅
bus.emit('ping', 'oops');                                        // ❌ type error
bus.on('unknown', () => {});                                     // ❌ type error

// Wildcard listeners receive the union of all payloads
bus.on('*', (event) => console.log(event.type, event.payload));

The generic is optional — new TabsBroadcast() stays fully permissive, so existing untyped code keeps working unchanged.

API

on(type, callback, layer?)

Register a persistent listener. Use '*' to capture every event in a layer.

bus.on('eventName', ({ payload }) => console.log(payload));
bus.on('*', (event) => console.log('any event:', event), 'APP_LAYER_0');

once(type, callback, layer?)

Like on, but the listener is removed after the first matching event.

onList(list) / onceList(list)

Register many listeners at once. Each item is [type, callback, layer?].

bus.onList([
  ['eventA', onA],
  ['eventB', onB, 'APP_LAYER_0'],
]);

off(type, layer?)

Unregister all callbacks of type. Omit layer to remove them from every layer.

bus.off('eventName');              // all layers
bus.off('eventName', 'APP_LAYER_0'); // one layer

deleteLayer(layer)

Remove an entire layer and all of its listeners.

bus.deleteLayer('APP_LAYER_0');

emit(type, payload?, layers?)

Emit a message to all listening tabs (and to this tab if listenOwnChannel is enabled). No-op on non-primary tabs when emitByPrimaryOnly is true. layers may be a single name or an array.

bus.emit('eventName');
bus.emit('eventName', { id: 1 });
bus.emit('eventName', { id: 1 }, ['APP_LAYER_0', 'APP_LAYER_3']);

setConfig(config)

Override configuration properties (merged over defaults).

destroy(delay?)

Tear down the channel and election worker, clear all listeners/layers, relinquish leadership, and reset the singleton. Optional delay (ms) defers destruction.

await bus.destroy();     // immediately
await bus.destroy(500);  // after 500ms

getEvents() / getLayers()

Inspect registered listeners and layer names.

bus.getEvents();  // TCallbackItem[]
bus.getLayers();  // e.g. ['APP_LAYER_0', 'APP_LAYER_1']

primary: boolean

Whether the current tab is the primary tab.

if (bus.primary) bus.emit('tick');

isPrimary() still exists but is deprecated — use the primary property.

Plugins

A plugin is a function (instance) => void that extends the instance with new methods or wraps existing ones. Register it with use.

const emitToAllLayersPlugin = (instance) => {
  instance.emitToAllLayers = function (type, payload) {
    this.emit(type, payload, Object.keys(this.layers));
  };
};

const bus = new TabsBroadcast();
bus.use(emitToAllLayersPlugin);
bus.emitToAllLayers('globalEvent', { synced: true });
// Auto-logging plugin
const autoLogPlugin = (instance) => {
  const originalEmit = instance.emit.bind(instance);
  instance.emit = (type, payload, layers) => {
    console.log('[LOG] emit', type, payload, layers);
    originalEmit(type, payload, layers);
  };
};

Security considerations

BroadcastChannel is same-origin, but it is not a private channel. Any script running on the same origin — third-party tags, browser extensions, or injected code via XSS — that knows the channel name can post messages to it. Therefore:

  • Treat every received payload as untrusted input. Do not pass it directly to innerHTML, eval, navigation, or other sensitive sinks without validating it first.
  • TabsBroadcast validates the shape of incoming messages and silently ignores malformed ones. Incoming messages are only delivered to listeners of already-registered layers — a remote message can never create new layers in your instance.
  • If you need to make the channel harder to target, set a non-default, hard-to-guess channelName.

Primary tab election

Election uses the Web Locks API (navigator.locks) when available: leadership is held for as long as the tab is alive and released automatically by the browser on close/crash — so there are no stale entries and no election races. Without Web Locks, the library falls back to a localStorage heartbeat with automatic stale-primary recovery and a deterministic tab-id tie-break. All election state is namespaced per channelName, so independent channels never contend for the same primary slot.

onBecomePrimary receives a { tabId } detail identifying the tab that became primary.

Browser support

Works in all modern browsers that implement BroadcastChannel (Chrome/Edge, Firefox, Safari 15.4+). Where the Web Locks API is available (the common case) it is used for election; otherwise the localStorage fallback kicks in automatically. SSR is safe — the library no-ops without a window.

Framework usage

A minimal React hook:

import { useEffect, useRef, useState } from 'react';
import TabsBroadcast from 'tabs-broadcast';

export function useTabsBroadcast() {
  const ref = useRef<TabsBroadcast>();
  const [isPrimary, setPrimary] = useState(false);

  if (!ref.current) {
    ref.current = new TabsBroadcast({ onBecomePrimary: () => setPrimary(true) });
    setPrimary(ref.current.primary);
  }

  // Note: TabsBroadcast is a singleton; destroy on full app teardown, not per-component.
  return { bus: ref.current, isPrimary };
}

Migration: 3.x → 4.0

4.0 is a correctness/security release with a few breaking changes:

  • Namespaced election statelocalStorage/lock keys are now per-channelName. State written by 3.x is not read by 4.0.
  • listenOwnChannel now defaults to false (matches the docs). Pass true explicitly to keep the old self-listening behavior.
  • onBecomePrimary detail is now { tabId } (the isPrimary field was removed).

See the CHANGELOG for the full list, including the off() and disableInternalErrors fixes and the Web Locks election.

Contributing

See CONTRIBUTING.md. In short:

npm install
npm test          # unit tests (Vitest + happy-dom)
npm run test:types # type-level tests
npm run lint
npm run build

License & author

MIT — see LICENSE.

Created by Andrei (Ravy) Rovnyi[email protected] · ravy.pro