Coordinate multi-tab uploads with the Web Locks API
Stop duplicate resumable uploads across browser tabs by replacing localStorage and BroadcastChannel hacks with navigator.locks and a React upload coordinator.
This article is published in English.
Concurrency means making progress on several pieces of work at once—or appearing to—so software stays responsive. A cook who chops while one pan simesters and another bakes is juggling tasks concurrently rather than finishing dishes strictly one after another.
Frontend work used to feel far from classic multi-threaded systems with locks, semaphores, and deadlocks. The modern web platform changed that. Between Workers, WebGL, Canvas, and Service Workers, browser apps now face coordination problems that look a lot like shared-memory races.
The problem
A marketplace engineering team needed reliable uploads for very large files—on the order of tens of gigabytes, up to about 20GB. Asking people to keep a tab open for the entire transfer is brittle. The product goal was resumable uploads: closing and reopening a tab should continue the transfer without a manual restart.
Resumability introduces a new failure mode. If several tabs restore the same in-flight upload, each tab may restart the transfer independently. The object store then receives duplicate streams, bandwidth is wasted, processing costs rise, and progress UI can thrash as tabs fight over the same job.
The awkward part is that each tab is doing something reasonable in isolation. Session restoration finds an incomplete upload record, the client resumes the multipart transfer, and the UI reports progress. Multiply that by two open tabs and the same “reasonable” path becomes two writers against one remote object key.
Without a coordinator, two restored tabs both believe they own the upload and both push bytes. The fix needs mutual exclusion across tabs; browsers expose that primitive as Web Locks.
Before the implementation details, it helps to recall what a lock is for.
First, what is a Lock?
A lock serializes access to a shared resource so callers observe consistent state. The textbook bank example has several ATMs reading and writing the same account balance at once. Without synchronization, withdrawals and deposits interleave unsafely.
With a lock, the first caller to acquire it performs its read or write; others wait until release. That same idea maps to “only one tab may resume this upload.”
The Landscape of Cross-Tab Coordination
Several older browser patterns try to coordinate tabs. They work until races, crashes, or protocol bugs appear.
Option 1: localStorage + Polling
A common DIY mutex stores a flag in localStorage:
// Simplified example - DON'T use this
if (!localStorage.getItem('upload-lock')) {
localStorage.setItem('upload-lock', Date.now());
// Start upload
}
Problems show up quickly:
- Race conditions: reads and writes are not atomic
- Stale locks: a crashed tab leaves the flag set forever
- Polling overhead: other tabs must keep checking
- Event limitations: storage events fire in other tabs, not the writer
Option 2: Broadcast Channel API
Tabs can negotiate with cross-document messaging via BroadcastChannel:
const channel = new BroadcastChannel('upload-coordination');
channel.postMessage({ type: 'REQUEST_UPLOAD_PERMISSION' });
Challenges remain:
- No mutex primitive: you invent a state machine yourself
- Race conditions: overlapping messages still conflict
- Complex protocol: heartbeats, crash detection, and conflict rules are all hand-rolled
- Error-prone: ordering and timing bugs are common
Enter the Web Locks API: Browser-Native Mutex
Native Web Locks give you an atomic browser mutex. Useful properties include:
- Atomic lock acquisition — avoids the localStorage race
- Automatic cleanup — closing or crashing a tab releases the lock
- Non-blocking options — keep the UI responsive
- Simple API — no custom messaging protocol
- Browser compatibility — covered in the MDN compatibility table for current engines
Our Implementation: useUploadCoordinator
A custom React hook wraps the strategy for upload ownership.
The Core Strategy
Treat interactive starts differently from silent restores. When someone explicitly chooses a file, prefer a non-blocking acquire so the UI never waits on another tab. If the lock is free, take it and upload; if it is already held, still decide based on product rules rather than freezing the page.
Restored uploads should be stricter: only the first tab that reclaims ownership should continue the transfer. Later tabs that see the lock unavailable should leave the existing owner alone instead of opening a second stream.
// 1. Fresh uploads (user-initiated): Non-blocking
// Try to get the lock, but proceed regardless
navigator.locks.request(LOCK_NAME,
{ ifAvailable: true },
async (lock) => {
if (lock) {
// Hold the lock until upload completes
await holdLockUntilComplete(abortController);
}
// Upload proceeds either way - never block the user
}
);
// 2. Restored uploads: Conditional
// Only proceed if we get the lock
navigator.locks.request(LOCK_NAME,
{ ifAvailable: true },
async (lock) => {
if (lock) {
uploader.emit("restore-confirmed"); // Proceed with restore
await holdLockUntilComplete(abortController);
}
// If no lock, skip silently - another tab is handling it
}
);
Key Implementation Details
Hold the lock until the transfer finishes or aborts:
const holdLockUntilComplete = (abortController: AbortController) => {
return new Promise<void>((resolve) => {
const cleanup = () => {
// Remove all listeners and resolve (releasing the lock)
uploader.off("complete", cleanup);
uploader.off("error", cleanup);
uploader.off("cancel-all", cleanup);
resolve();
};
// Release lock on any terminal event
uploader.on("complete", cleanup);
uploader.on("error", cleanup);
uploader.on("cancel-all", cleanup);
// Also release if component unmounts
abortController.signal.addEventListener("abort", cleanup);
});
};
Detect support and degrade safely when the API is missing:
const checkWebLocksAvailable = (): boolean => {
return typeof navigator !== 'undefined' && 'locks' in navigator;
};
// Fallback behavior
if (!checkWebLocksAvailable()) {
// Allow restores to proceed (accept risk of duplicates)
uploader.emit("restore-confirmed");
return;
}
The Benefits in Practice
After shipping the coordinator, the team observed:
- Zero duplicate uploads caused by multi-tab restore races
- Better user experience — coordination did not freeze the UI
- Reduced infrastructure costs — fewer duplicate transfers and stored objects
- Simpler codebase — no polling loops or hand-built state machines
Only the tab that wins the lock continues the upload; losers detect the held lock and stand down.
Real-World Considerations
When to Use This Pattern:
- File uploads that restore after reload
- Background sync that must run once
- Any singleton job that should not run in parallel tabs
Performance Impact:
- Minimal — no polling loop
- Automatic cleanup limits leaked waiters
- Non-blocking requests keep interaction responsive
Testing Strategy:
- Exercise every supported browser you ship
- Simulate abrupt tab crashes while a lock is held
- Confirm fallback behavior where Web Locks are unavailable
Conclusion
Cross-tab coordination does not require a fragile custom protocol. Rely on the platform Web Locks mutex: it cleans up on tab exit and supports non-blocking acquisition. See the Web Locks documentation for API details.
Practical rules that held up in production:
- Never block user actions — prefer non-blocking locks for gestures the user starts
- Fail safely — degrade instead of breaking uploads entirely
- Keep it simple — use platform primitives rather than rebuilding them
Those three rules matter more than any particular helper name. A polished coordinator that freezes the click path will feel worse than a simpler fallback that occasionally asks the user to retry. Likewise, a clever messaging protocol that forgets crash cleanup will recreate the stale-lock problem that made localStorage painful.
The same pattern applies whenever tabs must elect a single owner for shared work, not only for large uploads. Background sync queues, offline mutation flushers, and one-shot migration scripts all benefit from the same “one winner, many polite losers” shape.