What is the Web Locks API?
The Web Locks API is a browser-native mechanism that allows web applications to acquire and release locks asynchronously, ensuring coordinated access to shared resources across multiple execution contexts. It provides a way to prevent race conditions when multiple tabs, windows, or workers within the same origin need to operate on the same data, such as IndexedDB databases, localStorage, or shared memory.
At its core, the API exposes a global LockManager interface via navigator.locks, enabling developers to request named locks. These locks can be exclusive (only one holder at a time) or shared (multiple readers simultaneously), and they are automatically released when the work is done, even if an error occurs. The API is designed around the concept of a lock scope—a callback or async function that executes while the lock is held—ensuring that locks are never left dangling.
Why Does It Matter?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Modern web applications often run in multiple contexts: a user might open several tabs of the same application, or the app may have service workers, web workers, and the main thread all competing for shared resources. Without a locking mechanism, concurrent operations can lead to:
- Data corruption: Two tabs writing to IndexedDB simultaneously may overwrite each other's changes.
- Race conditions: A read operation could see an inconsistent state if a write is in progress.
- Unreliable caching strategies: Service workers updating caches while the main thread is fetching resources can cause mismatches.
- Duplicate work: Multiple tabs performing expensive initialization or API calls unnecessarily.
The Web Locks API provides a standardized, lightweight way to synchronize these operations without relying on complex message-passing hacks or fragile polling mechanisms. It integrates seamlessly with promises and async/await, making it straightforward to incorporate into modern JavaScript codebases.
Core Concepts and API Overview
The LockManager Interface
Access the LockManager through navigator.locks. It offers three primary methods:
request(name, [options], callback)– Acquires a lock and runs the callback, returning a promise that resolves with the callback's result.query()– Returns a snapshot of currently held locks (useful for debugging).request()withmode: 'exclusive'(default) or'shared'.
Requesting a Lock
The simplest usage is to request an exclusive lock on a given name. The callback receives a lock object (which can be passed to abort controllers) and returns a promise or a value. The lock is held until the callback's promise settles.
navigator.locks.request('my-resource', async (lock) => {
console.log('Lock acquired:', lock.name);
// Perform critical work
await doSomething();
// Lock automatically released after this callback completes
});
Lock Options and Modes
The second argument to request() can be an options object:
mode:'exclusive'(default) – only one client can hold the lock at a time.'shared'– multiple clients can hold the lock concurrently, but only if no exclusive lock is held.ifAvailable: Iftrue, the request will not queue; if the lock is not immediately available, the callback is not invoked and the promise resolves withundefined. Useful for non-critical operations.signal: AnAbortSignalto cancel a queued lock request if it takes too long.steal: (Not widely supported) Allows taking an already-held lock from another context.
navigator.locks.request('database-write', { mode: 'exclusive', ifAvailable: true, signal: AbortSignal.timeout(5000) }, async (lock) => {
if (lock === null) {
console.log('Lock not available, skipping.');
return;
}
// Critical section
});
Using Abort Controllers for Timeouts
Because lock requests are queued by default, a request may wait indefinitely if another client holds the lock. To avoid blocking the UI or causing deadlocks, always provide an abort signal with a timeout. You can use AbortSignal.timeout() (modern browsers) or manually create an AbortController.
const controller = new AbortController();
setTimeout(() => controller.abort(), 3000); // Abort after 3 seconds
try {
await navigator.locks.request('critical-path', { signal: controller.signal }, async () => {
// ...
});
} catch (err) {
if (err.name === 'AbortError') {
console.warn('Lock request timed out.');
}
}
Asynchronous Lock Work
The callback must return a promise to keep the lock alive during asynchronous operations. If you return a non-promise value, the lock is released immediately. The lock object passed to the callback is a snapshot of the lock's name and mode; it doesn't provide any control methods.
Always await inside the callback or return a promise that encompasses all your async work.
Practical Examples
Example 1: Synchronizing Shared State Across Tabs
Suppose you have a shared counter stored in localStorage, and multiple tabs can increment it. Without locking, two tabs reading and writing simultaneously could miss increments. Using an exclusive lock ensures atomic read-modify-write.
async function incrementSharedCounter() {
await navigator.locks.request('counter-lock', { mode: 'exclusive' }, async () => {
const current = parseInt(localStorage.getItem('shared-counter') || '0', 10);
const next = current + 1;
// Simulate some async work (e.g., server sync)
await new Promise(resolve => setTimeout(resolve, 100));
localStorage.setItem('shared-counter', next.toString());
console.log('Counter incremented to', next);
});
}
// Call this from any tab
incrementSharedCounter();
Example 2: Preventing Race Conditions in IndexedDB
When multiple tabs write to the same object store, transactions can overlap in ways that cause conflicts. The Web Locks API can serialize these writes, ensuring only one tab performs a write operation at a time.
async function saveDocument(docId, content) {
await navigator.locks.request(`doc-${docId}`, { mode: 'exclusive' }, async () => {
const db = await openDatabase(); // your helper
const tx = db.transaction('documents', 'readwrite');
const store = tx.objectStore('documents');
store.put({ id: docId, content, updated: Date.now() });
await tx.done;
console.log('Document saved safely');
});
}
Example 3: Sequential Resource Loading
Imagine you want to load a large resource (e.g., a WASM module) only once, even if multiple tabs request it simultaneously. You can use a shared lock with a check-and-set pattern, but a more reliable approach is an exclusive lock to coordinate the initial fetch, then allow shared reads later. Here's a simple single-flight loader:
let cachedData = null;
async function loadCriticalData() {
// Try shared lock for reading cached data
const result = await navigator.locks.request('data-loader', { mode: 'shared', ifAvailable: true }, async () => {
if (cachedData) {
console.log('Using cached data');
return cachedData;
}
// If cache empty, release shared and retry exclusive
return undefined;
});
if (result) return result;
// Fallback: acquire exclusive lock to fetch and cache
return await navigator.locks.request('data-loader', { mode: 'exclusive' }, async () => {
// Double-check in case another tab filled the cache while we waited
if (cachedData) return cachedData;
console.log('Fetching data...');
const response = await fetch('/api/large-data');
cachedData = await response.json();
return cachedData;
});
}
Example 4: Using Locks with Service Workers
Service workers can also use the Web Locks API (they share the same origin). This is useful for coordinating cache updates. The following service worker code ensures only one instance performs a cache purge at a time.
self.addEventListener('message', async (event) => {
if (event.data.type === 'PURGE_CACHE') {
try {
await navigator.locks.request('cache-purge', { mode: 'exclusive', signal: AbortSignal.timeout(10000) }, async () => {
const cacheNames = await caches.keys();
for (const name of cacheNames) {
await caches.delete(name);
}
console.log('All caches purged');
event.ports[0].postMessage({ success: true });
});
} catch (err) {
event.ports[0].postMessage({ success: false, error: err.message });
}
}
});
Best Practices
- Keep lock scopes short: Avoid long-running operations inside a lock callback. Release the lock as soon as possible to prevent blocking other clients. If you must do extensive work, consider breaking it into multiple smaller locks or using a queue.
- Always use timeouts: Attach an
AbortSignalto prevent indefinite waiting, which could cause deadlocks or poor user experience.AbortSignal.timeout()is concise and widely supported. - Prefer shared locks for read-only operations: If multiple clients only need to read data without modifying it, use
mode: 'shared'. This allows concurrency while blocking exclusive writers. - Avoid deadlocks: If your application uses multiple locks, always acquire them in a consistent order across all contexts. For example, if you need locks 'A' and 'B', always request 'A' first then 'B' inside the callback. Never hold a lock while requesting another one that could create a circular wait.
- Use
ifAvailablefor non-critical tasks: For operations like analytics or opportunistic clean-up, setifAvailable: true. If the lock is busy, the operation simply skips without queuing, preserving responsiveness. - Handle abort errors gracefully: Catch
AbortErrorand either retry or provide fallback behavior. Don't let an abort crash the application. - Don't rely on locks for cross-origin security: The API is scoped to the same origin (protocol + host + port). It cannot prevent a different origin from accessing resources like IndexedDB (which is also per-origin). Locks are for coordination, not security.
- Monitor with
navigator.locks.query(): Usequery()to inspect held locks during development and debugging. It returns an object withheldandpendingarrays containing lock info.
Conclusion
The Web Locks API fills a crucial gap in the web platform by offering a native, promise-based locking mechanism for coordinating concurrent operations within the same origin. It empowers developers to build more robust web applications that safely manage shared state across multiple tabs, workers, and service workers. By embracing best practices—short lock scopes, abort signals, and appropriate lock modes—you can eliminate data races, reduce redundant work, and deliver a seamless, consistent experience to users even in complex multi-context environments. As browser support continues to expand, integrating the Web Locks API into your modern web application toolkit is a wise investment in reliability and maintainability.