Web Locks
Why locks?
Section titled “Why 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.
navigator.locks.request(name, callback)
Section titled “navigator.locks.request(name, callback)”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.
Exclusive vs shared mode
Section titled “Exclusive vs shared mode”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 concurrentlyawait 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 finishedawait navigator.locks.request('demo:db-read', { mode: 'exclusive' }, async (lock) => { await db.put({ key: 'x', value: 42 });});ifAvailable — non-blocking try
Section titled “ifAvailable — non-blocking try”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(); });navigator.locks.query()
Section titled “navigator.locks.query()”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);Runnable: acquire an exclusive lock
Section titled “Runnable: acquire an exclusive lock”Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
Web Locks exclusive mode | Reliably prevents a cross-tab race condition, since the browser queues requests FIFO for you | If the callback never resolves/rejects, the lock is never released, and other tabs deadlock waiting forever |
Web Locks shared mode | Multiple tabs can read at once, without blocking a read-heavy workload | Must 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 user | Used in the wrong place for work that must run reliably, it can silently skip something important |
Common mistakes
Section titled “Common mistakes”- 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
AbortSignalvia thesignaloption 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
exclusivemode for read-only work — this needlessly serializes every read, whensharedmode should be used instead to let reads happen concurrently.