Web Locks API — Coordinating Across Tabs
debt(d7/e3/b3/t5)
Closest to 'only careful code review or runtime testing' (d7). No detection tools are listed in detection_hints.tools. The common mistakes (not awaiting the outer call, generic lock names, missing timeout) are behavioral concurrency bugs that won't surface in static analysis or default linting — they only manifest at runtime when two tabs actually compete, making them invisible until tested under real concurrent conditions.
Closest to 'simple parameterised fix' (e3). The quick_fix is a single wrapping pattern — await navigator.locks.request('unique-lock-name', async () => { ... }) — but applying it correctly requires reviewing all call sites for the awaiting mistake and renaming generic lock names. That's a small but non-trivial refactor within affected components, slightly more than a one-line patch.
Closest to 'localised tax' (b3). Web Locks apply within an origin's concurrency-sensitive code paths. The choice imposes a naming discipline (unique lock names per feature) and a fallback concern for older Safari, but these costs are contained to the modules that actually do cross-tab coordination. The rest of the codebase is unaffected.
Closest to 'notable trap' (t5). The misconception field identifies the canonical trap: developers coming from localStorage-flag polling assume Web Locks are just a nicer version of that pattern, missing the automatic crash-release guarantee. Additionally, not awaiting the outer request() call is a documented gotcha that causes subtle concurrency bugs. These are real traps most developers encounter, but they're documented and learnable.
Also Known As
TL;DR
Explanation
navigator.locks.request(name, callback) acquires a lock with the given name and calls the callback when the lock is held. If another context holds the same lock, the request queues until the lock is released. When the callback's returned Promise resolves, the lock is released. Mode 'exclusive' (default) allows only one holder; mode 'shared' allows multiple concurrent readers. Locks are automatically released if the tab crashes or is closed. Common use cases: preventing duplicate form submissions across tabs, coordinating IndexedDB migrations, ensuring only one tab performs a background sync, and preventing race conditions in shared-worker scenarios.
Common Misconception
Why It Matters
Common Mistakes
- Not awaiting the navigator.locks.request() call — the lock is released when the callback Promise resolves; if you don't await the outer call, the surrounding code continues before the lock work finishes.
- Using the same lock name for unrelated operations — lock names are global within an origin; a generic name like 'sync' will block unrelated features.
- Not handling lock acquisition timeout — by default, lock requests queue indefinitely; use { signal: AbortSignal.timeout(5000) } to abort if the lock isn't available within 5 seconds.
- Expecting Web Locks to work in Safari before 15.4 — check caniuse.com; for older browsers fall back to a BroadcastChannel-based coordination approach.
Code Examples
// ❌ Race condition — multiple tabs submit the same form simultaneously
async function submitOrder(data) {
// Tab A and Tab B both call this at the same time
const response = await fetch('/api/orders', {
method: 'POST', body: JSON.stringify(data)
});
// Two orders created — customer charged twice
}
// ✅ Web Locks — only one tab processes at a time
async function submitOrder(data) {
await navigator.locks.request('order-submit', async () => {
// Only one tab holds this lock at a time
const response = await fetch('/api/orders', {
method: 'POST', body: JSON.stringify(data)
});
return response.json();
});
}
// Shared lock — multiple readers, exclusive writer
async function readCache() {
return navigator.locks.request(
'cache-lock',
{ mode: 'shared' }, // Multiple tabs can read simultaneously
async () => getFromCache()
);
}
// Non-blocking lock query
const state = await navigator.locks.query();
console.log('Held locks:', state.held);
console.log('Pending:', state.pending);