Web Locks API: Coordinating Work Across Browser Tabs
The Web Locks API lets JavaScript acquire named locks shared across tabs, so only one tab does work like a token refresh or a write at a time.
The Web Locks API lets JavaScript request a named lock that’s shared across every tab, iframe, and worker on the same origin, so only one execution context can hold it at a time. It solves a coordination problem that’s easy to overlook until a user opens your app in two tabs at once: without some form of cross-tab locking, both tabs can independently kick off the same expensive or state-mutating operation — a token refresh, a write to shared storage, a background sync — and step on each other.
The problem with multiple tabs
Browser tabs from the same origin share storage — localStorage, IndexedDB, cookies — but each tab runs its own independent JavaScript context. Nothing stops two tabs from reading the same access token, both deciding it’s expired, and both firing a refresh request at the same time. Depending on your auth server, that can invalidate the first refresh token before the second request uses it, logging the user out of both tabs. This is the same shape of problem as a race condition in any concurrent system — it’s just less obvious in a browser because tabs don’t feel like concurrent processes to most developers.
Before the Web Locks API, working around this meant improvising a lock out of localStorage (writing a flag, polling for it, and racing against the same read-then-write gap you were trying to avoid) or coordinating through a BroadcastChannel with hand-rolled leader-election logic. Both approaches work, but both are the kind of code that looks correct until a specific timing window proves otherwise.
Basic usage
The core method is navigator.locks.request(), which takes a lock name and a callback. The callback only runs once the lock is available, and the lock releases automatically when the callback’s returned promise settles:
async function refreshToken() {
return navigator.locks.request("token-refresh", async () => {
const token = await fetchNewToken();
localStorage.setItem("token", token);
return token;
});
}
If another tab is already inside a request("token-refresh", ...) callback, this call queues and waits — it doesn’t fail, throw, or run the callback early. Once the holder’s promise resolves or rejects, the lock passes to the next queued request in order (by default). You never manually call unlock(); the lifecycle is tied entirely to the callback’s promise, which avoids the classic bug of an acquired lock that never gets released because an error path forgot to clean up.
Modes: exclusive and shared
Locks default to "exclusive" mode, where only one holder can have the lock at any time — the pattern above. You can also request "shared" mode, which allows multiple holders simultaneously as long as none of them holds it exclusively:
navigator.locks.request("cache-read", { mode: "shared" }, async () => {
return readFromCache();
});
This maps to the classic readers-writer lock pattern: any number of tabs can read concurrently, but a write needs to request "cache-read" in exclusive mode, which will wait for all current shared readers to finish and block new ones from starting until it completes.
Non-blocking checks with ifAvailable
Sometimes you don’t want to wait for a lock — you want to know immediately whether you’re the tab responsible for some task, and skip it otherwise. The ifAvailable option makes the request non-blocking: the callback runs with lock set to null if the lock couldn’t be acquired immediately, instead of queuing.
navigator.locks.request("leader-election", { ifAvailable: true }, async (lock) => {
if (!lock) return; // another tab already holds it
startBackgroundSync();
});
This is the pattern for cross-tab leader election: every tab tries to acquire the same lock with ifAvailable, exactly one succeeds, and that tab becomes responsible for the shared work (like a single WebSocket connection or a polling loop) while the others stay idle.
Inspecting current locks
navigator.locks.query() returns a snapshot of held and pending locks, useful for debugging or for showing UI state (“syncing in another tab”) without needing to acquire a lock yourself.
Web Locks vs. alternatives
| Web Locks API | localStorage flag polling | SharedWorker | |
|---|---|---|---|
| Cross-tab coordination | Built-in, native | Manual, race-prone | Manual, but centralized |
| Blocks until available | Yes, via queueing | No — needs a poll loop | No — needs message passing |
| Auto-release on crash/close | Yes | No — stale flags can persist | Depends on implementation |
| Browser support | Modern browsers | Universal | No Safari support historically |
The auto-release behavior is a meaningful advantage over hand-rolled localStorage locks: if a tab crashes or is force-closed mid-operation, the browser releases its held locks automatically, so other tabs aren’t left waiting on a lock that will never be freed.
The takeaway
The Web Locks API gives JavaScript real cross-tab mutual exclusion — exclusive and shared modes, automatic release tied to a promise, and non-blocking checks for leader election — without the polling and manual cleanup that localStorage-based locking required. Reach for it whenever multiple tabs of the same app might independently attempt the same stateful operation, from token refreshes to writes against a shared IndexedDB store.
Tagged
Keep reading
Takina · · 4 min read Svelte 5 Runes Explained
Svelte 5 runes like $state and $derived replace the old reactive-assignment magic with explicit function calls that work anywhere in a file.
Takina · · 4 min read Shallow Copy vs Deep Copy in JavaScript
A shallow copy duplicates an object's top-level properties but shares nested references; a deep copy duplicates everything recursively. How to do each.
Takina · · 4 min read Canvas vs SVG: Choosing the Right Graphics API
Canvas draws immediate-mode pixels you redraw yourself; SVG keeps a live, styleable DOM of vector shapes. Here's how to pick between the two.