Provides leadership election and communication in the browser across tabs and workers using the Locks API and BroadcastChannel. It works in modern browsers.
The Locks API allows us to have a very reliable leadership election, with virtually no delay in database or server connections and app startup time. When the existing leader is closed, the next tab will become the new leader immediately. The Tab interface allows calls and messages to be queued before a leader is elected and sent afterwards. The Tab interface supports everything you need to have all tabs communicate with one leader for loading, saving, and syncing data between tabs, including calling API methods the leader provides, broadcasting messages to other tabs, and state syncing.
npm install --save tab-election
import { Tab } from 'tab-election';
const tab = new Tab();
tab.waitForLeadership(() => {
// establish websocket, database connection, or whatever is needed as the leader
});
If a tab needs to stop being a leader (or waiting to become one) you can call tab.relinquishLeadership()
or the
function passed into tab.waitForLeadership((relinquishLeadership) => { })
. To completely close all connections with
other tabs and allow for garbage collection, call tab.close()
.
import { Tab } from 'tab-election';
const tab = new Tab('namespace');
tab.waitForLeadership((relinquishLeadership) => {
// establish websocket, database connection, or whatever is needed as the leader, return an API
return {
async loadData() {
// return await db.load(...);
},
letItGo() {
relinquishLeadership();
}
}
});
if (somethingHappens) {
tab.relinquishLeadership();
}
// ... sometime later, perhaps a tab is stale or goes into another state that doesn't need/want leadership
tab.close();
The tab.waitForLeadership()
method can be async. Calls to the leader will be queued while the API is initialized. The
waitForLeadership
method returns a promise which will resolve with a boolean
. If resolved with true
, the
leadership was relinquished while the tab was the leader. When false
, it was relinquished before taking leadership.
import { Tab } from 'tab-election';
const tab = new Tab('namespace');
tab.waitForLeadership(async () => {
// establish websocket, database connection, or whatever is needed as the leader, return an API
return {
async loadData() {
// return await db.load(...);
},
}
}).then(wasLeader => {
console.log('This tab the current leader:', wasLeader);
}, error => {
console.error('There was an error initializing the leader API', error);
});
Errors thrown within API methods will be returned to the caller and thrown in that context. E.g. if a tab calls
import { Tab } from 'tab-election';
const tab = new Tab('namespace');
tab.waitForLeadership(async () => {
// establish websocket, database connection, or whatever is needed as the leader, return an API
return {
async loadData() {
// This exception is forwarded on to the caller to handle
throw new Error('Cannot load the data');
},
}
});
async function loadData() {
try {
// This will recieve an error 'Cannot load the data' from the leader and can be handled here
return await tab.call('loadData');
} catch(err) {
console.error('Error loading data from leader', err);
}
}
To communicate between tabs, send and receive messages.
import { Tab } from 'tab-election';
const tab = new Tab('namespace');
tab.addEventListener('message', event => console.log(event.data));
tab.send('This is a test'); // will not send to self, only to other tabs
To keep state (any important data) between the current leader and the other tabs, use state()
. Use this to let the
other tabs know when the leader is syncing, whether it is online, or if any errors have occured. state()
will return
the current state of the leader and state(data)
will set the current state if the tab is the current leader.
The state object can contain anything that is supported by the Structured Clone Algorithm including Dates, RegExes, Sets, and Maps.
import { Tab } from 'tab-election';
const tab = new Tab('namespace');
tab.waitForLeadership(() => {
// establish websocket, database connection, or whatever is needed as the leader
tab.setState({ connected: false });
// connect to the server ...
tab.setState({ connected: true });
});
tab.addEventListener('state', event => console.log('The leader is connected to the server?', event.data.connected));
To allow tabs to call methods on the leader (including the leader), use the call()
method. The return result is always
asyncronous. The API that is callable should be returned from the waitForLeadership
callback. If the leader has
established a connection to the server and/or database, this may be used for other tabs to get/save data through that
single connection.
import { Tab } from 'tab-election';
const tab = new Tab('namespace');
tab.waitForLeadership(async () => {
// Can have async instructions here. Calls to `call` in any tab will be queued until the API is returned.
const db = await connectToTheDatabase();
return { db };
});
const result = await tab.call('db.saveRecord', { myData: 'foobar' });
if (result === true) {
console.log('Successfully saved');
}
If a tab wants to make calls to the leader, send and receive messages, and know the state, but it does not want to ever
become the leader, then don't call waitForLeadership
. This is useful when workers are used for leadership and UI
contexts make the requests and display state.
import { Tab } from 'tab-election';
const tab = new Tab('namespace');
const result = await tab.call('saveData', { myData: 'foobar' });
if (result === true) {
console.log('Successfully saved');
}
For more complex applications, tab-election provides a Hub & Spoke architecture that simplifies multi-tab coordination with type-safe service registration and RPC communication. The Hub runs in a SharedWorker, WebWorker, or elected tab to manage shared services, while Spokes in each tab communicate with the Hub through typed proxies.
Services must define a namespace
property for compile-time safety:
import { Hub, Spoke, Service } from 'tab-election';
// Define a service class with required namespace
class DatabaseService implements Service {
readonly namespace = 'db';
hub: Hub | undefined;
async init(hub: Hub) {
this.hub = hub;
this.db = await openDatabase();
}
async getUser(id: string): Promise<User> {
return await this.db.get('users', id);
}
async saveUser(user: User): Promise<void> {
await this.db.put('users', user);
this.hub?.emit('user-saved', { user }); // Notify all connected tabs
}
}
class AuthService implements Service {
readonly namespace = 'auth';
async login(credentials: LoginData): Promise<Token> {
// Authentication logic...
}
}
const hub = new Hub(hub => {
hub.register(new DatabaseService());
hub.register(new AuthService());
});
const spoke = new Spoke({
workerUrl: 'hub.js',
name: 'app-session',
version: '1.0.0'
});
// Type-safe client access - fully typed methods and return values
const db = spoke.client<DatabaseService>('db');
const auth = spoke.client<AuthService>('auth');
const auth = spoke.client<AuthService>('wrong'); // ❌ TypeScript error, namespace must match (safety feature)
// All method calls are fully typed
const user = await db.getUser('123'); // Returns Promise<User>
const token = await auth.login(credentials); // Returns Promise<Token>
// Listen for service events
const unsubscribe = db.on('user-saved', (payload) => {
console.log('User was updated:', payload.user);
});
- Compile-time namespace validation: Impossible to mismatch namespace strings with service classes
- Full type inference: Method signatures, parameters, and return types are all preserved
- IntelliSense support: Full autocomplete and type checking in your IDE
- Refactoring safety: Renaming methods or changing signatures is caught at compile time
The Hub & Spoke pattern is ideal when you need centralized resource management, type-safe inter-tab communication, or want to isolate heavy operations in a worker thread.