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.
Table of Contents
- 1. Why tabs need to be coordinated
- 2. Basics: navigator.locks.request in detail
- 3. Real example: token refresh without duplicates
- 4. Singleton tab pattern with exclusive locks
- 5. Queues and fair ordering
- 6. Non blocking checks with ifAvailable
- 7. Lifecycle, timeouts and AbortController
- 8. Debugging: making locks visible in Chrome DevTools
- 9. Web Locks API compared to alternatives
- 10. Summary
- 11. FAQ
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.