Leader election

Leader election is an interesting problem in the field of distributed computing. Fundamentally, it entails choosing a process from a group of multiple candidates, which may be distributed across various machines and data centers, to take on the role of a coordinator. In the event that the current leader fails, times out, or becomes disconnected from the network, it is replaced with a new leader. One common way of solving leader election is implementing a consensus algorithm like Paxos or Raft. Many databases achieve horizontal scalability through architectures that rely on these algorithms.

Why would you need leader election in a web browser? One of the most common use cases is multi-tab applications (or independent micro front-ends) that require a connection to a centralized resource, such as a WebSocket server or a browser database API. It is typically undesirable to have multiple instances of an application, running in separate tabs, simultaneously connecting to the same server or writing to the same database without proper coordination. This approach can be highly inefficient (as it means opening multiple connections to the resource when only one is needed) and, depending on the synchronization requirements of your application, potentially incorrect.

One possible solution to the problem would be using an inter-tab communication channel, like Broadcast Channel, to implement a consensus algorithm. While this method could work, it places the entire responsibility on the application developer and may be excessively complex for the task at hand. After all, the browser environment is much simpler and more predictable than a collection of multi-region data centers.

An alternative solution would be spawning a shared worker and moving all the centralized logic to it. This approach works out of the box but requires implementing a postMessage-based interface to communicate with the worker. This solution may also not be viable in a micro front-end architecture where a single shell application dynamically imports multiple independent micro front-ends at runtime. In such cases, either the shell application must spawn the shared worker, leading to coupling the shell with the communication protocol, or the micro front-ends must determine which of them can spawn the worker, reintroducing the leader-election problem. Additionally, shared workers are not supported by Chromium-based mobile browsers at the time of writing.

Web Locks API

The Web Locks API is an interesting API that is supported by all evergreen browsers in secure contexts; it implements a lock synchronization primitive where one or more scripts pass a promise to navigator.locks.request() to request a lock. Once the lock is acquired, the promise is executed; when the promise resolves, the lock is released and can be acquired by another request. Web locks are globally available, identified by their name, and they also support different modes of access (exclusive and shared) to the same named lock, making it easy to express multi-level concurrent problems like readers-writers.

What is more interesting for our consensus problem is that lock names are shared between tabs scoped to the same origin; this means that multiple tabs served from the same site can synchronize on the same named lock, making it ideal for our leader election problem: All tabs can simply request a lock (using a pre-agreed name) and whatever tab acquires the lock first knows it is the leader. When the leader tab gets closed or relinquishes the lock, another (random) tab will automatically acquire the lock and become the new leader, and so on. The browser will do the heavy lifting, with no need of heartbeats or any other kind of explicit co-ordination from the user.

Implementation

export function requestLeadership(id: string, cb: (id: string) => void) {
  let resolve: () => void;

  const p = new Promise<void>((res) => {
    resolve = res;
  });

  navigator.locks.request(id, () => {
    cb(id);
    return p;
  });

  return () => resolve();
}

This is all it takes for a working implementation; the requestLeadership function takes an id (to identify the lock name) and a callback function that gets executed whenever the calling script becomes the leader. It also returns a function that, when called when a tab is leader, voluntarily gives up leadership by explicitly resolving the promise passed to the lock. This function can be utilized schematically as follows:

let amILeader = false;

const relinquishLeadership = requestLeadership("leader-election", () => {
  amILeader = true;
  initLeader(); // whatever a tab does when it becomes leader: for example, listen to a BroadcastChannel to get things to do from another tab
});

//...

/* This function is absolutely optional and simulates a tab voluntarily relinquishing leadership
 * in favour of another; in practice, this is almost never needed, since when a tab is closed
 * another will automatically become leader without any user intervention */

function abdicate() {
  amILeader = false;
  relinquishLeadership();
}