Lightweight browser tab communication and leader election library using BroadcastChannel and localStorage
A lightweight library for cross-tab communication and leader election in browser environments. Perfect for coordinating multiple tabs/windows and electing a single leader tab.
Microscopic: weighs 6.3KB minified (2.1KB gzipped)
Reliable: leader election with lease-based heartbeat mechanism
Modern: async iterables for generator-based message streams
Type-Safe: full TypeScript support
Zero Dependencies: uses native Browser APIs only (BroadcastChannel, localStorage)
Flexible: callback-based or generator-based APIs, your choice
Try purrtabby in your browser with our interactive demo. Test cross-tab communication and leader election between multiple tabs.
Learn about purrtabby's internal architecture and design decisions.
- Cross-tab communication using BroadcastChannel
- Leader election with localStorage-based lease and heartbeat
- Generator-based streams for async iteration
- TypeScript support
- Browser compatible (requires BroadcastChannel and localStorage support)
- Zero dependencies (uses native Browser APIs)
- Tiny bundle size
- AbortSignal support for stream cancellation
npm:
npm install purrtabbyUMD build (via unpkg):
<script src="https://unpkg.com/purrtabby/dist/index.global.js"></script>Runtime:
- Browser: Modern browsers with BroadcastChannel and localStorage support
- Node.js: Not supported (requires browser APIs)
Development:
- Node.js: 24.13.0 (tested with this version)
import { createBus } from 'purrtabby';
const bus = createBus({
channel: 'my-app-channel',
});
// Subscribe to specific message types
const unsubscribe = bus.subscribe('user-action', (message) => {
console.log('Received:', message.payload);
console.log('From tab:', message.tabId);
});
// Publish messages to all tabs
bus.publish('user-action', { action: 'click', target: 'button' });
// Subscribe to all messages
bus.subscribeAll((message) => {
console.log('Any message:', message.type, message.payload);
});
// Cleanup
unsubscribe();
bus.close();import { createLeaderElector } from 'purrtabby';
const leader = createLeaderElector({
key: 'my-app-leader',
tabId: 'tab-1', // Optional: auto-generated if not provided
leaseMs: 5000, // Lease duration in milliseconds
heartbeatMs: 2000, // Heartbeat interval
jitterMs: 500, // Jitter to avoid thundering herd
});
// Start leader election
leader.start();
// Check if this tab is the leader
if (leader.isLeader()) {
console.log('This tab is the leader!');
}
// Listen for leader events
leader.on('acquire', () => {
console.log('Became the leader!');
});
leader.on('lose', (event) => {
console.log('Lost leadership:', event.meta?.newLeader);
});
leader.on('change', (event) => {
console.log('Leadership changed to:', event.meta?.newLeader);
});
// Stop leader election
leader.stop();purrtabby provides two ways to consume messages and events: callbacks and generators. Choose based on your needs:
- Callbacks: Simple, event-driven, good for one-off handlers
- Generators: Modern async/await patterns, better for complex flows, supports AbortSignal
const bus = createBus({ channel: 'my-channel' });
// Subscribe to specific message types
const unsubscribeMessage = bus.subscribe('user-action', (message) => {
console.log('Message:', message.payload);
});
// Subscribe to all messages
const unsubscribeAll = bus.subscribeAll((message) => {
console.log('Any message:', message.type, message.payload);
});
// Unsubscribe when done
unsubscribeMessage();
unsubscribeAll();
const leader = createLeaderElector({
key: 'my-leader',
tabId: 'tab-1',
});
leader.start();
// Subscribe to specific leader events
const unsubscribeAcquired = leader.on('acquire', (event) => {
console.log('Became leader');
});
const unsubscribeLost = leader.on('lose', (event) => {
console.log('Lost leadership');
});
// Subscribe to all leader events
const unsubscribeAllEvents = leader.onAll((event) => {
console.log('Leader event:', event.type);
});
// Unsubscribe when done
unsubscribeAcquired();
unsubscribeLost();
unsubscribeAllEvents();const bus = createBus({ channel: 'my-channel' });
// Consume messages as async iterable
(async () => {
for await (const message of bus.stream()) {
console.log('Received:', message.type, message.payload);
}
})();
const leader = createLeaderElector({
key: 'my-leader',
tabId: 'tab-1',
});
leader.start();
// Consume leader events as async iterable
(async () => {
for await (const event of leader.stream()) {
console.log('Leader event:', event.type, event.ts);
}
})();const controller = new AbortController();
(async () => {
for await (const message of bus.stream({ signal: controller.signal })) {
console.log(message);
if (shouldStop) {
controller.abort();
}
}
})();Creates a new TabBus instance for cross-tab communication.
| Option | Type | Default | Description |
|---|---|---|---|
channel |
string |
required | BroadcastChannel name |
tabId |
string |
auto-generated | Unique tab identifier |
Publishes a message to all tabs listening on the same channel.
bus.publish('user-action', { action: 'click' });Subscribes to messages of a specific type. Returns an unsubscribe function.
const unsubscribe = bus.subscribe('user-action', (message) => {
console.log(message);
});Subscribes to all messages regardless of type. Returns an unsubscribe function.
const unsubscribe = bus.subscribeAll((message) => {
console.log(message);
});Returns an async iterable of all messages.
for await (const message of bus.stream({ signal: abortSignal })) {
console.log(message);
}Returns the current tab's unique identifier.
Closes the bus and cleans up resources.
Creates a new LeaderElector instance for leader election.
| Option | Type | Default | Description |
|---|---|---|---|
key |
string |
required | localStorage key for leader lease |
tabId |
string |
required | Unique tab identifier |
leaseMs |
number |
5000 |
Lease duration in milliseconds |
heartbeatMs |
number |
2000 |
Heartbeat interval in milliseconds |
jitterMs |
number |
500 |
Jitter range to avoid synchronization |
buffer |
BufferConfig |
{ size: 100, overflow: 'oldest' } |
Buffer configuration for stream generators |
BufferConfig:
size: Maximum queue size (default: 100)overflow: Overflow policy -'oldest'(drop oldest),'newest'(drop newest), or'error'(throw error)
Starts the leader election process.
Stops leader election and releases leadership if held.
Returns true if this tab is currently the leader.
Subscribes to a specific leader event. Returns an unsubscribe function.
Events:
'acquire'- This tab became the leader'lose'- This tab lost leadership'change'- Leadership changed to another tab
Subscribes to all leader events. Returns an unsubscribe function.
Returns an async iterable of leader events.
Returns the current tab's unique identifier.
import { createBus, createLeaderElector } from 'purrtabby';
// Set up cross-tab communication
const bus = createBus({ channel: 'my-app' });
// Set up leader election
const leader = createLeaderElector({
key: 'my-app-leader',
tabId: `tab-${Date.now()}`,
leaseMs: 5000,
heartbeatMs: 2000,
});
// Start leader election
leader.start();
// Handle leader events
leader.on('acquire', () => {
console.log('This tab is now the leader');
// Only the leader performs certain tasks
bus.publish('leader-announcement', { tabId: leader.getTabId() });
});
leader.on('lose', () => {
console.log('This tab is no longer the leader');
});
// Listen for messages from other tabs
bus.subscribe('user-action', (message) => {
console.log('User action from tab:', message.tabId, message.payload);
// If we're the leader, process the action
if (leader.isLeader()) {
console.log('Processing action as leader');
}
});
// Publish user actions
bus.publish('user-action', { action: 'click', target: 'button' });
// Cleanup
leader.stop();
bus.close();Using with purrcat (WebSocket)
📖 Full WebSocket Usage Guide → - Learn how to share WebSocket connections across tabs, implement request-response patterns, and handle edge cases.
import createSocket from 'purrcat';
import { createBus, createLeaderElector } from 'purrtabby';
const bus = createBus({ channel: 'ws-coordinator' });
const leader = createLeaderElector({
key: 'ws-leader',
tabId: `tab-${Date.now()}`,
});
leader.start();
// Only the leader maintains WebSocket connection
leader.on('acquire', () => {
const socket = createSocket({
url: 'wss://api.example.com/ws',
});
// Forward messages from WebSocket to all tabs
socket.onMessage((message) => {
bus.publish('ws-message', message);
});
// Forward messages from tabs to WebSocket
bus.subscribe('send-to-ws', (message) => {
socket.send(message.payload);
});
});
// All tabs can receive WebSocket messages
bus.subscribe('ws-message', (message) => {
console.log('WebSocket message:', message.payload);
});
// Any tab can send to WebSocket (leader forwards it)
bus.publish('send-to-ws', { type: 'ping' });MIT
