Introduction to the Web Locks API
The Web Locks API is a relatively new browser feature that allows web applications to asynchronously acquire and hold locks — named, scoped synchronization primitives — across tabs, windows, and workers within the same origin. Before this API existed, developers had to resort to hacky workarounds like localStorage events, BroadcastChannel polling, or shared workers to coordinate concurrent work between tabs. The Web Locks API provides a clean, native, promise-based solution to this long-standing problem.
At its core, a "lock" is a guarantee that only one execution context (a tab, iframe, or worker) can perform a particular operation at a time. This is invaluable when multiple tabs might otherwise attempt to perform the same expensive or destructive operation simultaneously — for example, syncing data to a server, migrating an IndexedDB schema, or polling a rate-limited API.
Why the Web Locks API Matters
Modern web applications frequently run across multiple tabs. A user might open your email client in three tabs, and all three could try to fetch new mail at the same time. Without coordination, you waste bandwidth, risk race conditions, and may corrupt shared client-side state. The Web Locks API solves this by letting you say, "Only one tab should do this at a time," in a way that is reliable, atomic, and survives tab crashes.
Browser Support and Feature Detection
The Web Locks API is supported in all modern browsers including Chrome, Edge, Firefox, and Safari. It is available in secure contexts only — meaning https:// or localhost. Before using it, always perform feature detection:
if ('locks' in navigator) {
console.log('Web Locks API is supported');
} else {
console.log('Web Locks API is not supported in this browser');
// Fall back to a different coordination strategy
}
Because the API is asynchronous and promise-based, you can also wrap it in async/await syntax for cleaner code, which we will do throughout this tutorial.
Core Concepts
Lock Names and Scope
Every lock has a name — an arbitrary string you choose. Locks with the same name are mutually exclusive by default. Locks with different names are independent and can be held simultaneously. The namespace for lock names is per-origin, meaning all pages from https://example.com share the same lock space, but https://other.com has its own separate space.
Exclusive vs. Shared Locks
By default, locks are exclusive: only one client can hold a lock with a given name at a time. The API also supports shared locks, which allow multiple clients to hold the same lock simultaneously, as long as no exclusive lock is held. This mirrors the classic read-write lock pattern:
- Multiple shared locks can coexist.
- An exclusive lock cannot coexist with any other lock of the same name.
- Requests are queued in the order they are made (FIFO), with some fairness rules to prevent starvation.
The Lock Lifecycle
A lock is acquired by calling navigator.locks.request(), passing a name, optional options, and a callback. The lock is held for the duration of the callback's returned promise. Once that promise resolves or rejects, the lock is automatically released. You never manually release a lock — this design prevents leaks caused by forgotten cleanup.
Basic Usage: Acquiring a Lock
The simplest way to use the API is to request an exclusive lock and perform work inside the callback:
navigator.locks.request('my_resource', async (lock) => {
// While this async function runs, no other tab can acquire 'my_resource'
console.log('Lock acquired:', lock.name);
await doExpensiveWork();
console.log('Work complete, lock will be released');
});
If another tab already holds the my_resource lock, this request will wait in the queue until it becomes available. The promise returned by navigator.locks.request() resolves only after the callback completes.
Using async/await
async function syncData() {
await navigator.locks.request('data-sync', async () => {
const data = await fetchLatestFromServer();
await saveToIndexedDB(data);
});
console.log('Sync finished and lock released');
}
syncData();
Handling Lock Acquisition Failure
Sometimes you do not want to wait indefinitely for a lock. You can pass an options object with mode and ifAvailable to control this behavior.
Try Without Waiting: ifAvailable
Setting ifAvailable: true means the request will only acquire the lock if it is immediately available. Otherwise, the callback runs with null instead of a lock object:
navigator.locks.request('refresh_token', { ifAvailable: true }, async (lock) => {
if (!lock) {
console.log('Another tab is already refreshing. Skipping.');
return;
}
// We got the lock — perform the refresh
await refreshToken();
});
This pattern is extremely common for "only one tab should do this" scenarios like polling or token refresh.
Shared Mode
Pass mode: 'shared' to acquire a shared lock. This is useful when multiple readers can safely access a resource simultaneously, but writers need exclusive access:
// Multiple tabs can read at the same time
navigator.locks.request('cache', { mode: 'shared' }, async () => {
await readFromCache();
});
// Only one tab can write at a time, and no readers can be active
navigator.locks.request('cache', { mode: 'exclusive' }, async () => {
await writeToCache();
});
Setting a Timeout with signal
You can pass an AbortSignal to cancel a pending lock request if it takes too long to acquire. If the signal aborts before the lock is granted, the request is removed from the queue and the returned promise rejects with an AbortError:
async function acquireWithTimeout(name, timeoutMs, callback) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await navigator.locks.request(name, { signal: controller.signal }, callback);
} catch (err) {
if (err.name === 'AbortError') {
console.log(`Timed out waiting for lock "${name}"`);
return null;
}
throw err;
} finally {
clearTimeout(timer);
}
}
await acquireWithTimeout('critical_section', 5000, async () => {
await doWork();
});
Note that the signal only affects the waiting phase. Once the lock is acquired and your callback is running, aborting the signal has no effect on the lock itself.
Stealing Locks with steal
In rare cases, you may need to forcibly take a lock from another holder — for example, if a tab is unresponsive. Setting steal: true causes any existing holder of the lock to be released immediately, and the new request is granted:
navigator.locks.request('stuck_resource', { steal: true }, async (lock) => {
console.log('Stole the lock from previous holder');
await recoverState();
});
Use steal with caution. The previous holder's callback will continue running, but its lock object will be marked as released. You should check lock.mode or design your code to handle interruption gracefully if you rely on stealing.
Inspecting Held Locks
The navigator.locks.query() method returns a snapshot of the current state of the lock manager for your origin. This is useful for debugging and for building UI that shows what work is in progress:
const state = await navigator.locks.query();
console.log(state);
// Example output:
// {
// held: [{ name: 'data-sync', mode: 'exclusive', clientId: 'abc123' }],
// pending: [{ name: 'data-sync', mode: 'shared', clientId: 'def456' }]
// }
The clientId is a unique identifier for the execution context (tab or worker) that holds or is waiting for the lock. You can use this to distinguish between your own requests and those from other tabs.
Practical Example: Single-Tab Server Polling
One of the most common use cases is ensuring only one tab polls a server at a time. Here is a complete, production-ready pattern:
const POLL_INTERVAL = 30000;
async function pollOnce() {
await navigator.locks.request('server-poll', { ifAvailable: true }, async (lock) => {
if (!lock) {
// Another tab is handling polling — skip this round
return;
}
try {
const response = await fetch('/api/updates');
const updates = await response.json();
await applyUpdates(updates);
// Notify other tabs that new data is available
broadcastNewData(updates);
} catch (err) {
console.error('Polling failed:', err);
}
});
}
function startPolling() {
pollOnce();
setInterval(pollOnce, POLL_INTERVAL);
}
startPolling();
With this approach, even if the user has ten tabs open, only one will actually hit the server on each interval. The others will see the lock is held and skip gracefully.
Practical Example: IndexedDB Schema Migration
Another classic scenario is database migration. If multiple tabs open simultaneously after a deploy, you want only one to run the migration while the others wait:
async function ensureSchema() {
await navigator.locks.request('idb-migration', async () => {
const db = await openDB('mydb', 1);
const currentVersion = db.version;
if (currentVersion < 2) {
console.log('Running migration to version 2...');
await migrateToV2(db);
console.log('Migration complete');
} else {
console.log('Schema already up to date');
}
});
}
await ensureSchema();
Because the lock is exclusive by default, the second tab to open will block at navigator.locks.request until the first tab finishes migrating. Once the lock is released, the second tab acquires it, checks the version, sees it is already up to date, and proceeds immediately.
Best Practices
- Keep callbacks short. The lock is held for the entire duration of your callback. Avoid long-running operations that do not need to be protected.
- Use descriptive lock names. Names like
'idb-migration'or'token-refresh'make debugging much easier than generic names like'lock1'. - Prefer
ifAvailablefor optional work. If a task does not strictly need to run, do not make other tabs wait for it. - Handle errors inside the callback. If your callback throws, the lock is still released, but the error propagates to the caller of
request(). Wrap risky operations in try/catch. - Avoid holding locks across user interactions. Locks are not meant to gate UI. They are for coordinating background work.
- Use shared mode for read-heavy workloads. If many tabs need to read the same resource concurrently, shared locks prevent unnecessary serialization.
- Always feature-detect. Provide a fallback for older browsers, even if support is now broad.
- Do not rely on locks for security. Locks are a coordination mechanism, not a security boundary. Malicious code on the same origin can ignore them entirely.
Common Pitfalls
Forgetting That Locks Are Per-Origin
Locks do not cross origin boundaries. If your app is served from multiple subdomains, each subdomain has its own lock namespace. Use a shared origin or coordinate via a service worker if you need cross-subdomain synchronization.
Assuming Locks Persist Across Reloads
When a tab reloads or closes, all locks it holds are released automatically. This is good for safety but means you cannot rely on a lock to persist across page reloads. If you need durable coordination, combine locks with IndexedDB or a server-side mechanism.
Deadlocks from Nested Lock Requests
If you acquire lock A and then, inside that callback, request lock B — while another tab acquires lock B and then requests lock A — you can create a deadlock. The Web Locks API does not detect deadlocks automatically. To avoid this, always acquire locks in a consistent global order, or acquire multiple locks in a single request using the array form:
navigator.locks.request(['lockA', 'lockB'], async () => {
// Both locks are held atomically — no deadlock possible
await doWork();
});
When you pass an array of names, the API acquires all of them together or none of them, eliminating the possibility of partial acquisition deadlocks.
Conclusion
The Web Locks API fills a gap that web developers have worked around for years. By providing a native, promise-based mechanism for cross-tab synchronization, it makes patterns like single-tab polling, schema migration, and read-write coordination straightforward and reliable. The API is small — essentially one method, navigator.locks.request(), plus query() for inspection — but its options for shared mode, timeouts, stealing, and conditional acquisition cover the vast majority of real-world coordination needs. By following the best practices of keeping callbacks short, using descriptive names, and feature-detecting for older browsers, you can safely adopt the Web Locks API today and eliminate an entire class of concurrency bugs from your multi-tab web applications.