Modern web applications often allow users to open the same application in multiple browser tabs. This is convenient, but it can create data consistency problems when several tabs attempt to update the same information at the same time.
Consider a task management application that stores a list of tasks in IndexedDB. A user opens the application in two tabs. The first tab adds a task while the second tab updates an existing task. If both tabs read the same old data and later write their own versions, one update may overwrite the other.
These problems are difficult to reproduce because they depend on timing. Both tabs may work correctly during ordinary testing, but a conflict can appear when their operations overlap.
The Web Locks API provides a browser-level mechanism for coordinating access to shared resources. It allows scripts from the same origin to request a named lock before performing a sensitive operation. While one script holds the lock, another script requesting the same exclusive lock must wait until the first releases it.
This article explains how Web Locks work, how to use them to coordinate updates, and what they can and cannot guarantee.
Understanding the Web Locks API
The Web Locks API lets browser contexts coordinate work through named locks. A context can request a lock, perform an operation while holding it, and release the lock when the operation completes.
A lock is associated with its name and origin. Tabs from the same origin can coordinate by requesting the same lock name. Different names represent different coordination points.
For example, two tabs that update a shared task list could request a lock named task-list-update.
await navigator.locks.request("task-list-update", async () => {
// Perform the coordinated update here.
});
By default, a lock request is exclusive. Only one context can hold that exclusive lock at a time. Other requests for the same lock wait until the current holder releases it.
The lock is automatically released when the callback finishes, including when it throws an exception. This makes the callback a natural place to group the operations that must not overlap.
The API is asynchronous, so requesting a lock does not normally block the browser's main thread while waiting for another tab to release it.
Web Locks are designed for coordination between contexts belonging to the same origin. They are not a general-purpose distributed locking system for unrelated websites, devices, or server processes.
The Problem: Concurrent Updates From Multiple Tabs
Imagine a simple application that stores a counter in localStorage.
function incrementCounter() {
const current = Number(
localStorage.getItem("counter") ?? "0"
);
localStorage.setItem("counter", String(current + 1));
}
This code looks reasonable, but the read and write are separate operations.
Suppose two tabs start with a counter value of 5:
Tab A reads 5.
Tab B reads 5.
Tab A writes 6.
Tab B writes 6.
Both tabs attempted to increment the counter, but the final value is 6 instead of 7.
This is a lost-update problem. Each tab calculated its result from the same original value, so the later write replaced the earlier update.
Web Locks can coordinate the read-modify-write sequence:
async function incrementCounter() {
await navigator.locks.request(
"counter-update",
async () => {
const current = Number(
localStorage.getItem("counter") ?? "0"
);
localStorage.setItem(
"counter",
String(current + 1)
);
}
);
}
If both tabs use this same function and lock name, the operations execute one at a time. The second tab reads the value written by the first tab and increments it again.
The important detail is that the entire read-modify-write operation occurs inside the lock callback. Locking only the final write would not prevent both tabs from reading the same stale value.
This example demonstrates the coordination pattern, but localStorage is not a transactional database. For more complex data, use a storage mechanism with appropriate transaction guarantees.
Coordinating IndexedDB Updates
IndexedDB is often a better choice for structured client-side data. It supports transactions, object stores, indexes, and asynchronous operations.
IndexedDB already provides transactional guarantees for operations within its transactions. Therefore, Web Locks are not automatically required for every IndexedDB update.
However, an application may need a lock when several tabs coordinate a larger workflow that spans multiple transactions, shared in-memory state, or other resources.
Consider an application that performs a multi-step update to a task record. A simplified function might look like this:
async function updateTaskSafely(taskId, changes) {
await navigator.locks.request(
`task-update-${taskId}`,
async () => {
const task = await getTaskFromIndexedDB(taskId);
if (!task) {
throw new Error("Task not found");
}
const updatedTask = {
...task,
...changes,
updatedAt: new Date().toISOString()
};
await saveTaskToIndexedDB(updatedTask);
}
);
}
The functions getTaskFromIndexedDB and saveTaskToIndexedDB represent application-specific IndexedDB operations. They must be implemented using proper IndexedDB requests and transactions.
The lock name includes the task identifier, so updates to different tasks can proceed independently. Updates to the same task use the same lock and are serialized, provided every relevant tab follows the same locking convention.
This can reduce conflicts when several tabs perform read-modify-write workflows.
However, a Web Lock does not replace IndexedDB transactions. If the application needs atomicity across several database records, use an appropriate IndexedDB transaction. A lock coordinates cooperating browser contexts; it does not make separate database transactions atomic.
Choosing a Lock Name
The lock name determines which operations coordinate with each other.
A single application-wide lock is straightforward:
await navigator.locks.request(
"application-data",
async () => {
await updateApplicationData();
}
);
This prevents cooperating tabs from entering the protected section simultaneously, but it can also reduce concurrency. An update to one record may unnecessarily block an unrelated update.
For applications with independent resources, use more specific names:
function getTaskLockName(taskId) {
return `task-update-${taskId}`;
}
Then request the corresponding lock:
async function saveTask(taskId, taskData) {
await navigator.locks.request(
getTaskLockName(taskId),
async () => {
await persistTask(taskId, taskData);
}
);
}
Lock names should be consistent across all parts of the application that need to coordinate.
Avoid creating arbitrary lock names from unvalidated user input when a controlled identifier is available. Although lock names are not database permissions, predictable naming helps developers understand which operations can conflict.
Handling Errors and Waiting Requests
The callback passed to navigator.locks.request() can be asynchronous. The lock remains held until the returned promise settles.
This means that an unbounded network request or an operation that never completes can keep a lock occupied for a long time.
Keep protected operations as short as practical. Avoid holding a lock while waiting for unrelated user interaction, and be cautious about network requests inside the critical section.
For example:
async function refreshLocalRecord(recordId) {
const remoteData = await fetchRemoteRecord(recordId);
await navigator.locks.request(
`record-update-${recordId}`,
async () => {
await saveRecordToIndexedDB(recordId, remoteData);
}
);
}
In this example, the network request happens before the lock is acquired. The lock protects the local write, not the entire network operation.
This is appropriate when the fetched response can safely be saved after the request completes. If the operation depends on the latest local version, the code must check for stale data or re-read the record under the lock before applying the update.
Errors should also be handled at the application level:
async function saveSettings(settings) {
try {
await navigator.locks.request(
"settings-update",
async () => {
await persistSettings(settings);
}
);
} catch (error) {
console.error("Unable to save settings:", error);
throw error;
}
}
The example logs and rethrows the error so callers can decide how to report the failure. Production applications should avoid exposing sensitive details in user-facing error messages.
Using Lock Options
The Web Locks API supports options that change how requests behave.
Requesting a Lock Without Waiting
Sometimes an operation should proceed only if the lock is immediately available. For example, a background task may skip a synchronization cycle when another tab is already synchronizing.
The ifAvailable option allows a request to receive null instead of waiting when the lock cannot be granted immediately.
async function synchronizeIfIdle() {
await navigator.locks.request(
"background-sync",
{ ifAvailable: true },
async (lock) => {
if (!lock) {
console.log(
"Another tab is already synchronizing."
);
return;
}
await runSynchronization();
}
);
}
This pattern is useful for work that can be skipped and retried later. It is not suitable for an update that must eventually happen unless the application includes a reliable retry strategy.
Requesting a Shared Lock
The API also supports shared locks. Multiple requests for a shared lock can coexist, while an exclusive request for the same name must wait until conflicting shared locks are released.
Shared locks can be useful for read-only operations that need coordination with exclusive updates. However, the application must use a consistent locking strategy: a read that does not request the shared lock is not protected by that coordination.
Use exclusive locks for ordinary read-modify-write operations unless the requirements justify a more complex shared/exclusive arrangement.
Checking Lock Availability
During debugging, it can be useful to inspect the locks currently held or requested by contexts from the same origin.
async function logCurrentLocks() {
const state = await navigator.locks.query();
console.log("Held locks:", state.held);
console.log("Pending requests:", state.pending);
}
The returned information can help identify a lock that is held longer than expected or requests that are waiting behind another operation.
This is diagnostic information, not a replacement for proper logging. The application should also record when important operations begin, finish, or fail, without logging sensitive data.
If requests remain pending, investigate whether a callback is awaiting an operation that never settles or whether several operations are acquiring multiple locks in conflicting orders.
For workflows that require more than one lock, define a consistent acquisition order to reduce the risk of deadlock-like waiting patterns.
Browser Support and Failure Handling
The Web Locks API is available in modern browsers, but support depends on the browser and execution context. Check the browser compatibility requirements for the environments your application supports.
Before using the API, check that it is available:
if ("locks" in navigator) {
console.log("Web Locks API is available.");
} else {
console.log("Web Locks API is unavailable.");
}
If an application requires coordination to protect data integrity, silently proceeding without a lock may reintroduce the original race condition.
Choose a fallback strategy based on the application's requirements. Options include using a transactional storage design, disabling unsupported multi-tab workflows, or coordinating updates through a server.
For important business data, server-side validation and concurrency controls are often necessary even when browser-level locking is available.
Common Mistakes to Avoid
Locking Only the Write Operation
If two tabs read the same value before either acquires the lock, they can still calculate the same result. Protect the entire read-modify-write sequence.
Using Different Lock Names for the Same Resource
If one tab requests settings-update and another requests settings-save, the browser treats them as different locks. Standardize lock names across the application.
Holding Locks During Slow Operations
Long-running work delays other tabs. Keep the critical section small and perform unrelated network or computation work outside the lock where safe.
Assuming All Tabs Follow the Locking Rules
A tab that updates the same resource without requesting the lock can still create a conflict. Every cooperating code path must follow the same convention.
Treating Locks as Database Transactions
Web Locks coordinate scripts. They do not provide atomic transactions across IndexedDB, a server database, and other systems.
Ignoring Unsupported Browsers
If the application depends on locking for correctness, define a safe fallback rather than silently allowing uncoordinated writes.
Best Practices for Production Applications
Use stable, consistent lock names for shared resources.
Protect the complete read-modify-write operation.
Keep lock callbacks short and predictable.
Use IndexedDB transactions for atomic database operations.
Consider per-record locks when unrelated updates should proceed concurrently.
Use ifAvailable only when skipping an operation is safe.
Handle errors and rejected promises explicitly.
Check browser support and provide a suitable fallback.
Avoid acquiring multiple locks in inconsistent orders.
Keep server-side concurrency controls for important shared data.
Advantages and Limitations
The Web Locks API provides a straightforward way to coordinate work across browser contexts from the same origin. It can prevent cooperating tabs from simultaneously executing critical sections and is useful for shared settings, background synchronization, and client-side data workflows.
However, locks work only when the relevant code participates in the same locking protocol. They do not replace database transactions, protect data from unrelated processes, or guarantee consistency across devices. Long-held locks can also delay other operations.
For many applications, the best design combines Web Locks for browser-context coordination with transactional storage and server-side validation where needed.
Summary
Multiple browser tabs can create race conditions when they update shared data at the same time. The Web Locks API helps by allowing each tab to request a named lock before entering a critical section.
To use it effectively, protect the full operation that must not overlap, keep lock callbacks short, and ensure every relevant code path uses the same lock name. Combine browser-level coordination with IndexedDB transactions and server-side safeguards when data integrity is important.
Join the conversation! Your thoughts help the community grow.