How to coordinate browser tabs, prevent competing uploads, and keep asynchronous work under control.
By zhangxinxu. Adapted from the original article, with practical excerpts from the accompanying Web Locks Lab demo.
A user starts uploading a large file. The connection drops, so they reopen the upload page. Then they open another tab to check whether anything is happening.
Now two tabs may be trying to resume the same transfer.
Depending on the backend implementation, that can mean duplicate requests, competing updates, and progress indicators that disagree. A feature intended to make uploads more reliable has introduced a coordination problem.
The browser already provides a tool for this: the Web Locks API.
Why a Storage Flag Is Not Enough
The tempting solution is to save an “upload in progress” flag in localStorage. Each tab checks the flag before starting work.
The problem is the gap between checking and claiming ownership. Two tabs can both see an empty flag before either writes a value. Both then proceed.
Individual storage operations do not make the entire read–modify–write sequence atomic.
The supplied demo reproduces this pattern with an intentionally interleaved, in-memory simulation. It models two tabs reading the same counter before either writes:
let count = 0;
// Model two tabs reading the same shared value.
const readA = count;
const readB = count;
count = readA + 1;
count = readB + 1;

🎮 Try it live: Open the interactive demo to experience this yourself.
The final count is 1, even though two increments were intended. Both callers calculated their result from the same stale value.
A storage flag also needs recovery when its owner disappears. Polling adds repeated checks, and storage events provide notifications without making acquisition atomic.
BroadcastChannel helps tabs exchange messages, but messaging alone does not decide who owns a resource. Building ownership on top of it requires a coordination protocol.
What a Lock Actually Protects
Think of a lock as permission to enter a protected section of code.
A caller requests a named lock. Once granted, that caller performs its work. Competing exclusive requests for the same name wait until ownership is released.
Web Locks coordinate cooperating contexts within the same origin and storage partition. They do not automatically block code that ignores the locking convention. Every relevant caller must use the same resource name. Web Locks specification
The resource itself remains your responsibility. A lock does not refresh cached data or merge conflicting edits; it gives your code a protected interval in which to read current state and update it.
Let Asynchronous Tasks Take Turns
This excerpt from queueDemo() launches three tasks against one exclusive lock. The surrounding demo supplies the log element and delay helper.
const name = "web-locks-lab:counter:" + crypto.randomUUID();
let count = 0;
const tasks = [1, 2, 3].map(id => {
return navigator.locks.request(name, async () => {
log.textContent += `Task ${id}: acquired, reads ${count}\n`;
const previous = count;
await pause(800);
count = previous + 1;
log.textContent += `Task ${id}: writes ${count}, finishes\n`;
});
});
await Promise.allSettled(tasks);

🎮 Try it live: Open the interactive demo to experience this yourself.
Although the requests are created together, their protected operations run one at a time.
The key detail is the async callback: the lock remains held until the promise returned by that callback settles. Awaiting the delay keeps the read and subsequent write inside the protected interval. Web Locks API documentation
This example generates a unique lock name because its counter belongs to one page. For cross-tab coordination, every participating tab must use the same stable name.
Let One Tab Own the Upload
Queueing makes sense when every task should eventually execute. Duplicate upload attempts need a different policy: if another tab already owns the upload, skip this attempt.
That is what ifAvailable: true provides. If the request cannot be granted immediately, its callback receives null instead of waiting. The capitalization matters: the option is ifAvailable. LockManager.request() documentation
The following excerpt comes from uploadDemo(). It uses the demo’s existing simulateUpload() helper and interface elements:
await navigator.locks.request(
name,
{ ifAvailable: true },
async lock => {
if (!lock) {
log.textContent = `${label}: skipped; another caller owns the upload.`;
return;
}
const controller = new AbortController();
activeController = controller;
stop.disabled = false;
log.textContent = `${label}: acquired; uploading…`;
try {
await simulateUpload(controller.signal);
log.textContent = controller.signal.aborted
? "Upload cancelled. Work stopped; releasing lock."
: "Upload complete. Releasing lock.";
} finally {
activeController = null;
stop.disabled = true;
}
}
);

🎮 Try it live: Open the interactive demo to experience this yourself.
Here, name is the shared string "web-locks-lab:upload".
The winning caller holds the lock while the simulated transfer runs. A competing caller exits without starting another transfer. The finally block resets the interface; the browser releases the lock when the callback finishes.
The demo uses a real browser lock but a simulated transfer—no file is sent.
For a production uploader, derive the lock name from a stable upload-session identifier. A single global upload name would unnecessarily serialize unrelated files.
Also, acquisition only establishes ownership right now. Once a previous owner finishes, another tab can acquire the lock. Read the server’s current upload status after acquisition so that completed work is not started again.
Cancellation Must Stop the Work Before Releasing Ownership
There are two distinct cancellation cases.
While waiting for a lock, pass an AbortSignal through the request’s signal option. Aborting it cancels a pending acquisition.
After acquiring a lock, cancellation belongs to the operation itself. Aborting the request signal does not stop an already-running callback. LockManager.request() documentation
The upload demo follows the second pattern. Its Cancel button aborts the controller passed to simulateUpload(). That helper clears its timer before resolving, allowing the callback to finish only after the simulated work stops.
A real uploader needs the same ordering: stop scheduling chunks, cancel or settle active operations, and then let the protected callback finish. Releasing ownership while background work continues would allow overlapping activity.
Browser coordination also cannot undo requests already accepted by a server. Server-side upload identifiers, chunk validation, and duplicate handling remain necessary.
Choosing the Right Lock Behavior
The API supports several access policies:
| Option | Behavior | Typical use |
|---|---|---|
mode: "exclusive" | One exclusive owner at a time; the default | Upload ownership or protected updates |
mode: "shared" | Multiple shared owners can coexist | Coordinated readers |
ifAvailable: true | Returns null to the callback when immediate acquisition is unavailable | Skip duplicate work |
signal | Cancels acquisition while it is pending | Stop waiting for a resource |
steal: true | Preempts existing holders without stopping their code | Carefully designed recovery |
Shared readers and exclusive writers must request the same lock name for their access to be coordinated. The demo’s document controls illustrate this: readers can overlap, while an exclusive writer waits for conflicting holders. Web Locks API documentation
Treat steal carefully. Taking ownership does not terminate the previous owner’s JavaScript, so the two operations can still interfere. LockManager.request() documentation
Inspect Ownership Without Turning It Into Another Race
The demo’s final panel uses navigator.locks.query() to display held and pending locks.
This is useful for debugging: you can inspect lock names, modes, and client identifiers. But the result is only a snapshot. Ownership may change immediately afterward, so checking the snapshot cannot reserve a resource. Use request() to acquire it. Web Locks specification
To explore the lab:
- Run the race simulation and observe the lost update.
- Queue three tasks and watch them finish sequentially.
- Start an upload, then try a competing caller.
- Open the same URL in another tab and repeat.
- Refresh the lock snapshot while work is active.
The demo checks both window.isSecureContext and navigator.locks before enabling its native-lock controls. Serve it over HTTPS or a trusted local development origin such as localhost. Web Locks is broadly supported in modern browsers and requires a secure context. Web Locks API documentation
A Small API for an Expensive Class of Bugs
Web Locks is useful whenever cooperating browser contexts need to agree on who may act: resuming an upload, synchronizing local data, or updating a shared document.
Its most valuable rule is straightforward: give the resource a consistent name, acquire ownership before acting, and await the entire operation before releasing it.
That small boundary can remove a surprising amount of coordination code—and make opening a second tab an ordinary user action again.
Original author: zhangxinxu. Source: The Web Locks API: A Lifesaver When It Matters. The original permits full reproduction with author, source, and article links retained; commercial use requires contacting the author. This article is an educational adaptation with excerpts from the supplied demo.
Try It Yourself
Want to see these concepts in action? I’ve created an interactive demo where you can experiment with the code and see real-time results.
Explore more demos from my previous articles in the Demo Gallery.
Happy coding!