Skip to content

Web Locks

Multiple tabs of the same app can run simultaneously and may attempt to write to the same IndexedDB record or the same OPFS file at exactly the same moment. Without coordination, writes can race and the last writer silently wins (or, worse, data is corrupted). The Web Locks API provides a browser-native mutual-exclusion primitive that solves this — no server required.

To acquire a lock, pass a string name and an async callback. The browser holds the lock for as long as the callback’s returned Promise is pending, then releases it automatically.

await navigator.locks.request('demo:my-resource', async (lock) => {
// Inside here you hold the lock exclusively.
// No other tab can acquire 'demo:my-resource' until this async block resolves.
await doWork();
});
// Lock released here — next waiter can proceed.

If the lock is already held by another tab, request queues your callback until the lock is released. The queue is FIFO.

The default mode is exclusive — only one holder at a time. For read-heavy workloads you can use shared mode: multiple shared holders can overlap, but an exclusive request waits until all shared holders finish (like a read-write lock).

// Multiple tabs can hold this concurrently
await navigator.locks.request('demo:db-read', { mode: 'shared' }, async (lock) => {
const data = await db.getAll();
return data;
});
// This waits until every shared holder above has finished
await navigator.locks.request('demo:db-read', { mode: 'exclusive' }, async (lock) => {
await db.put({ key: 'x', value: 42 });
});

Pass ifAvailable: true to get null instead of queuing when the lock is already held. This is useful for “do work only if I can start immediately, otherwise skip this cycle”:

const result = await navigator.locks.request(
'demo:background-sync',
{ ifAvailable: true },
async (lock) => {
if (!lock) {
console.log('Lock busy — skipping this cycle');
return null;
}
// We have the lock
return await syncData();
}
);

query() returns a snapshot of all currently held and waiting locks — useful for debugging:

const state = await navigator.locks.query();
console.log('Held:', state.held);
console.log('Pending:', state.pending);
Browser Storage
OptionBenefitCost
Web Locks exclusive modeReliably prevents a cross-tab race condition, since the browser queues requests FIFO for youIf the callback never resolves/rejects, the lock is never released, and other tabs deadlock waiting forever
Web Locks shared modeMultiple tabs can read at once, without blocking a read-heavy workloadMust be paired correctly with exclusive, or you risk reading data while a writer is mid-update
ifAvailable: true (non-blocking)No queueing to wait for — good for background work that can skip a cycle without affecting the userUsed in the wrong place for work that must run reliably, it can silently skip something important
  • Requesting a lock with no timeout/abort strategy — if the tab holding the lock hangs or crashes mid-callback, e.g. a network hang, other tabs wait for that lock forever with no error. Pass an AbortSignal via the signal option to set a timeout.
  • Assuming you must call a “release” method yourself like a mutex in other languages — Web Locks are tied to the lifetime of the Promise the callback returns; the lock is only released automatically when that Promise settles. There’s no method to call directly.
  • Using exclusive mode for read-only work — this needlessly serializes every read, when shared mode should be used instead to let reads happen concurrently.
What happens if Tab A holds an exclusive lock named "x" and Tab B calls navigator.locks.request("x", cb) with no options?
In shared mode, how many tabs can hold the same lock simultaneously?
You pass `ifAvailable: true` and the lock is currently held. What does the callback receive?
When is a Web Lock released?