Web Locks API for Tab Coordination: Avoiding Race Conditions
AI generated
JS
() =>
JavaScript · Browser APIs · Concurrency
Web Locks API for Tab Coordination
Avoiding Race Conditions Between Browser Tabs

When a user opens the same web application in several tabs, many background tasks run in parallel without anyone noticing: token refresh, cache building, syncing with the server. The Web Locks API provides an exclusive lock across tabs of the same origin through navigator.locks, letting you coordinate such tasks and reliably prevent duplicate execution.

17 min read navigator.locks.request · ifAvailable · steal Chrome · Edge · Firefox · Safari 16.4+

1. Why tabs need to be coordinated

Modern single page applications often run in several tabs at once, a user opens the same application in a second window to work in parallel. Without coordination, this leads to classic race conditions: two tabs simultaneously notice that the access token has expired and both fire a refresh request. The server may invalidate the first refresh token as soon as the second one arrives, and one tab ends up logged out with an invalid token. The Web Locks API was built exactly for this problem.

Through navigator.locks, the Web Locks API provides an origin wide locking system that works across all open tabs, web workers and service workers of the same origin. Unlike localStorage events or BroadcastChannel messages, which only exchange information, the Web Locks API enforces real exclusivity: only one execution context holds a named lock at any time, all others wait or are rejected immediately depending on the mode.

Typical use cases for the Web Locks API are token refresh, initializing an IndexedDB schema only once, preventing duplicate analytics events on tab start, and electing a leader tab for tasks that should only run once, such as a WebSocket connection shared by all tabs.

2. Basics: navigator.locks.request in detail

The central entry point of the Web Locks API is navigator.locks.request(name, callback). The name uniquely identifies the lock within the origin, and the callback receives a Lock object and only runs once the lock has been granted. Crucially, the lock is released automatically once the promise returned by the callback settles, either fulfilled or rejected, there is no explicit unlock() method that could be forgotten.

This design of the Web Locks API prevents a whole class of bugs known from classic locking APIs: forgotten releases that leave resources blocked forever. The return value of request() is itself a promise resolved with the callback's return value, so results can be passed cleanly out of the locked section.


// Basic exclusive lock across all tabs of the same origin
async function withExclusiveLock(name, task) {
  return navigator.locks.request(name, async (lock) => {
    console.log(`Lock "${lock.name}" acquired in mode ${lock.mode}`);
    const result = await task();
    // No manual unlock needed — releases automatically when the
    // callback's promise settles, even if task() throws.
    return result;
  });
}

const total = await withExclusiveLock("cart-total", async () => {
  return computeCartTotal();
});

3. Real example: token refresh without duplicates

The classic example for the Web Locks API is token refresh in applications with several open tabs. Without coordination, every tab independently notices that the token is expiring and issues its own refresh request. With navigator.locks.request("token-refresh", ...), only one tab gets the lock, performs the refresh, stores the new token in localStorage or IndexedDB, and every other tab waits until the lock is released to then read the already refreshed token directly.

An important detail of this pattern with the Web Locks API is checking again after acquiring the lock whether the token is actually still expired. A waiting tab might only receive the lock after another tab has already performed the refresh, a repeated refresh would then be unnecessary and potentially harmful if the server invalidates refresh tokens after single use.


// Token refresh coordinated across tabs with the Web Locks API
async function getValidAccessToken() {
  let token = readTokenFromStorage();
  if (!isExpired(token)) return token;

  return navigator.locks.request("token-refresh", async () => {
    // Re-check: another tab may have refreshed while we were waiting
    token = readTokenFromStorage();
    if (!isExpired(token)) return token;

    const response = await fetch("/api/token/refresh", {
      method: "POST",
      credentials: "include",
    });
    const fresh = await response.json();
    writeTokenToStorage(fresh);
    return fresh.accessToken;
  });
}

4. Singleton tab pattern with exclusive locks

Another powerful pattern of the Web Locks API is electing a leader tab. Some tasks, such as a persistent WebSocket connection for live updates, should be maintained by exactly one tab, while all other tabs receive the incoming data forwarded through BroadcastChannel. The trick: one tab requests a lock that never gets released as long as the tab is alive, meaning the associated promise never resolves.

Every other tab also tries to acquire the same lock but stays queued until the current leader tab closes, at which point the browser automatically releases the lock. The next tab in the queue then automatically takes over the leader role. This pattern of the Web Locks API needs no manual heartbeat logic, because the browser guarantees the release on tab closure or crash.


// Leader election: exactly one tab owns the shared WebSocket connection
let isLeader = false;

navigator.locks.request("websocket-leader", { mode: "exclusive" }, () => {
  isLeader = true;
  const socket = new WebSocket("wss://api.mironsoft.de/live");

  socket.addEventListener("message", (event) => {
    // Fan out updates to every open tab through BroadcastChannel
    leaderChannel.postMessage(JSON.parse(event.data));
  });

  // Never resolve: hold the lock as long as this tab is the leader.
  // Releasing happens automatically when the tab closes or crashes.
  return new Promise(() => {});
});

5. Queues and fair ordering

When several tabs request the same lock at the same time, the Web Locks API forms an internal first in first out queue. Every request is processed in the order it arrived, guaranteeing predictable behavior without any tab being favored through repeated requests. This fairness guarantee matters particularly for tasks like sequential IndexedDB writes, where the order of operations affects the outcome.

The Web Locks API additionally supports shared locks through the mode { mode: "shared" }. Multiple readers can hold a shared lock at the same time, while an exclusive writer has to wait until all readers are done, classic reader writer locking known from database systems, now directly available in the browser.

6. Non blocking checks with ifAvailable

Not every use case of the Web Locks API wants to wait until a lock becomes free. The option { ifAvailable: true } immediately calls the callback with lock === null if the lock is already held by another context, instead of queuing the request. This suits tasks that are meant to run best effort anyway, such as a periodic cache refresh that simply tries again at the next interval if another tab is currently updating the same cache.

The option { steal: true } takes the opposite approach and immediately aborts an existing lock to hand it over to the new requester. Use this with caution, since the original holder of the lock has no chance to finish cleanly, it mainly suits recovery scenarios where a hung tab would never release a lock again.


// Non-blocking check: skip work if another tab already holds the lock
async function refreshCacheIfNoOneElseIs() {
  await navigator.locks.request(
    "cache-refresh",
    { ifAvailable: true },
    async (lock) => {
      if (lock === null) {
        console.log("Another tab is already refreshing the cache, skipping");
        return;
      }
      await rebuildLocalCache();
    }
  );
}

7. Lifecycle, timeouts and AbortController

Because navigator.locks.request() can wait indefinitely without additional options, integrating an AbortController is essential for production applications using the Web Locks API. The signal option implements a timeout: if time runs out before the lock is granted, the request aborts with an AbortError instead of leaving the user waiting forever.

Another important aspect: locks in the Web Locks API are tied to the lifecycle of the execution context. If a user closes a tab that currently holds a lock, the browser guarantees the lock is released, regardless of whether the associated JavaScript code finished cleanly. This differs fundamentally from server side locking mechanisms, where a crashed process needs a lease timeout to release the lock again.


// Abort a lock request after a timeout to avoid indefinite waiting
async function requestLockWithTimeout(name, task, timeoutMs = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await navigator.locks.request(
      name,
      { signal: controller.signal },
      task
    );
  } catch (err) {
    if (err.name === "AbortError") {
      console.warn(`Lock "${name}" not acquired within ${timeoutMs}ms`);
      return null;
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}

8. Debugging: making locks visible in Chrome DevTools

Chrome DevTools offers a dedicated view under Application, Locks that shows every active lock of the Web Locks API with its name, mode and involved client. This view is essential for diagnosing deadlocks or unexpectedly long waits, for example when a tab crashes before it was reloaded, and DevTools immediately shows whether the lock was still released correctly.

In addition, navigator.locks.query() exposes the same state programmatically as an object with held and pending arrays. This is useful for showing the current lock state of the Web Locks API in monitoring dashboards or debug overlays at runtime without opening DevTools, for example during automated end to end tests that deliberately try to provoke race conditions.

9. Web Locks API compared to alternatives

Before the Web Locks API, cross tab coordination problems were solved with workarounds that were either unreliable or considerably more complex. A direct comparison shows why the native API is the better choice in most cases.

Approach Mechanism Exclusivity guaranteed? Drawback
localStorage flag Manually set a boolean value No, race prone No atomic test and set
BroadcastChannel voting Exchange messages, wait out a timeout No, best effort only Complex timing logic required
Server side locking Redis lock or DB transaction Yes Network round trip, server dependency
Web Locks API navigator.locks.request() Yes, guaranteed by the browser Only within the same origin

The Web Locks API does not replace server side locking in every case, it is explicitly limited to coordination within a browser origin. But for purely client side problems like token refresh, leader election or IndexedDB initialization, it is the clearly simpler and more robust solution, without an additional network round trip and without custom timeout logic for crashed clients.

Mironsoft

JavaScript development, browser APIs and modern web applications

Duplicate requests from multiple tabs under control?

We integrate the Web Locks API into your application, for reliable token refresh, leader election between tabs and race free IndexedDB access.

Tab coordination

navigator.locks correctly applied for token refresh, caching and leader election

Robustness

Timeouts, AbortController and a fallback for browsers without support

Debugging setup

Lock state integrated into monitoring through navigator.locks.query()

10. Summary

The Web Locks API solves a problem that quickly leads to subtle bugs in applications with multiple open tabs: competing actions getting in each other's way. With navigator.locks.request() you can coordinate token refresh, elect a leader tab for shared resources and serialize access to IndexedDB, all without a server round trip and without manual release logic.

Anyone using the Web Locks API in production should always plan a timeout through AbortController, re check the initial condition after acquiring the lock, and use the Chrome DevTools view under Application, Locks for diagnosis. The automatic release on tab closure makes the API more robust than most custom built locking mechanisms and earns it a permanent place in the toolbox for multi tab applications.

Web Locks API for Tab Coordination — Key Takeaways

Exclusive locks

navigator.locks.request(name, callback) grants only one tab at a time access to a named resource.

Automatic release

No manual unlock method. The lock releases as soon as the callback promise settles or the tab closes.

Modes

ifAvailable for non blocking checks, steal for recovery, shared for reader writer patterns.

Diagnostics

Chrome DevTools under Application, Locks as well as navigator.locks.query() show the current lock state.

11. FAQ: Web Locks API for Tab Coordination

1What is the Web Locks API?
Exclusive or shared locks across all tabs of the same origin via navigator.locks.request(), to prevent race conditions.
2Manual release needed?
No, automatic on callback promise settlement or tab closure.
3Usable across different origins?
No, only within the same origin. Other domains need server side locking.
4Tab with lock crashes?
The browser guarantees release, regardless of clean code termination.
5Prevent indefinite waiting?
Use signal with AbortController to set a timeout that aborts with AbortError.
6What does ifAvailable do?
Callback receives lock equal to null immediately instead of waiting, if the lock is already held.
7What is steal good for?
Recovery scenarios where an existing lock would never be released otherwise.
8Elect a leader tab?
Request a lock with a never resolving promise, the queue takes over automatically on close.
9Debug locks?
Chrome DevTools, Application, Locks or programmatically navigator.locks.query().
10Browser support?
Chrome, Edge, Firefox fully, Safari from 16.4. Feature detection with 'locks' in navigator.