Handling concurrency on the Web with Web Locks API
Concurrency in programming is the ability to manage and execute multiple tasks or processes at the same time (or appear to), allowing programs to remain responsive and efficient. Think of it like a chef in a busy kitchen: they’re not cooking every dish from start to finish sequentially, but instead chopping vegetables while one pan simmers and another bakes; juggling progress on several tasks concurrently.
I’m a frontend engineer at OLX working mostly with React and NextJs, but I also came from a background in which I worked primarily in concurrent programs with Java. Creating multi-threaded applications can be a pain, dealing with locks, semaphores… and my favorite… deadlocks (very scary in large codebases). Once I transitioned fully to Web development, I never imagined I would have to deal with scenarios like this, and then here I am writing my first article about it.
Web development has turned into a very powerful ecosystem, from the previous era where the web was only about flashy animations using HTML marquee (a bit of sarcasm here 😆), dominated by JQuery, to a really powerful environment where you can use Workers, 2D and 3D animations with WebGL and Canvas, offline capabilities with Service Workers, and the list continues. This is evidence that the Web can now face really complex challenges, and it’s just a matter of time before you have to face similar ones.
The problem
Recently, at OLX, we faced the challenge of working with large file size uploads. Providing the best user experience is tricky when it comes to handling uploads, and most of the real use cases out there require the user to stay in the web app until the upload completes, but we didn’t want that. Since we are working with large file sizes (up to 20GB), we want the user to be free and do other stuff while this is uploading, so if the user closes and opens the browser or tab, the upload should continue with no manual intervention. For this, we had to provide a mechanism to resume uploads.
But here’s where things get messy. What if your user opens multiple tabs of your app? Suddenly, every tab tries to restore and resume the same upload simultaneously. The result? Multiple duplicate uploads hitting your S3 bucket, wasted bandwidth, unnecessary processing costs, and confused users watching their upload progress jump around unpredictably.
If you’re building file upload experiences in modern web applications, you’ve likely encountered this exact scenario. Today, I’ll walk you through a robust solution using the Web Locks API that we implemented to solve this coordination nightmare.
Get Cesar Contreras’s stories in your inbox
Join Medium for free to get updates from this writer.
But first, before going in depth into our solution, we need to understand synchronization and locks.
First, what is a Lock?
A lock is a mechanism that ensures a shared resource (variable, object, function invocation, etc.) can be accessed one at a time and every caller is guaranteed to receive the most recent information. You can think of a very popular scenario — a bank with multiple accounts in shared memory and several ATMs, withdrawing, depositing, and checking balances simultaneously. With no coordination (synchronization), this will be a mess, as you can see in the image below.
To solve this mess, we have to control access by locking read/write to the account. So, with this, the first caller to get the lock, no matter the action being performed (withdrawing, crediting, or checking balance), will acquire the lock. Others have to wait until the lock is finally released after the first acquirer finishes the operation. The following image shows how this looks after synchronizing account accesses.
Now that we understand how synchronization and locks work, let’s dive into how to put it into practice 🚀.
The Landscape of Cross-Tab Coordination
Before diving into our solution, let’s examine the options available for coordinating actions across browser tabs:
Option 1: localStorage + Polling
The “classic” approach involves using localStorage as a shared lock mechanism:
// Simplified example - DON'T use this
if (!localStorage.getItem('upload-lock')) {
localStorage.setItem('upload-lock', Date.now());
// Start upload
}The problems:
- Race conditions: localStorage read/write operations aren’t atomic
- Stale locks: If a tab crashes, the lock persists forever
- Polling overhead: You need to continuously check the lock status
- Event limitations: Storage events only fire in other tabs, not the current one
Option 2: Broadcast Channel API
This approach uses messaging between tabs to coordinate with the Broadcast Channel API:
const channel = new BroadcastChannel('upload-coordination');
channel.postMessage({ type: 'REQUEST_UPLOAD_PERMISSION' });The challenges:
- No mutex primitive: You’re building a state machine from scratch
- Race conditions: Simultaneous messages can create conflicts
- Complex protocol: You need to handle heartbeats, crash detection, and conflict resolution manually
- Error-prone: Message ordering and timing issues are common
Enter the Web Locks API: Browser-Native Mutex
The Web Locks API provides exactly what we need: a true, atomic mutex system built directly into the browser. Here’s what makes it special:
- Atomic lock acquisition — No race conditions
- Automatic cleanup — Locks are released when tabs close or crash
- Non-blocking options — Avoid freezing the UI
- Simple API — No complex protocols to implement
- Browser compatibility — Supported in all browsers 😃
Our Implementation: useUploadCoordinator
Here’s how we solved the problem with a custom React hook:
The Core Strategy
Two different approaches for two scenarios:
// 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
Lock Lifecycle Management:
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);
});
};Browser Compatibility & Graceful Degradation:
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
Since implementing this solution, we’ve seen:
- Zero duplicate uploads from cross-tab coordination issues
- Better user experience — uploads never freeze due to coordination logic
- Reduced infrastructure costs — no more duplicate processing and storage
- Simpler codebase — no complex state machines or polling logic
And now, this is how our solution looks:
Real-World Considerations
When to Use This Pattern:
- File uploads with persistence/restoration
- Background sync operations
- Any singleton operations that should only run in one tab
Performance Impact:
- Minimal — no polling overhead
- Automatic cleanup prevents memory leaks
- Non-blocking design ensures UI responsiveness
Testing Strategy:
- Test across all target browsers
- Verify behavior when tabs crash unexpectedly
- Confirm graceful degradation in unsupported environments
Conclusion
Cross-tab coordination doesn’t have to be a nightmare. The Web Locks API provides a robust, browser-native solution that eliminates the complexity and fragility of custom coordination protocols.
While building this solution, we learned that the key to successful cross-tab coordination is:
- Never block user actions — use non-blocking locks for user-initiated operations
- Fail safely — graceful degradation is better than broken functionality
- Keep it simple — leverage platform primitives instead of rebuilding them
The Web Locks API isn’t just for upload coordination; any time you need mutual exclusion across tabs, it’s worth considering. As browser support continues to improve, it’s becoming an increasingly viable tool in the modern web developer’s toolkit.








