Preventing race conditions: My experience with LockManager in JavaScript
Recently, I faced an interesting challenge at work: implementing a feature that would:
-
Receive a message with payload via WebSockets.
-
Trigger a background service worker (SW) to run a function with the received payload and update entities in DB.
-
Return the result from the SW to the server.
Please see my illustration of the feature flow below:
This looked encouraging and unexplored, so I thought about possible constraints and implementations.
Problem
Imagine a scenario where a user has multiple tabs open simultaneously on your platform. The server sends a message to every opened tab (every tab subscribed to the websocket connection) and it runs the SW function N times — where N is the count of opened tabs. Running it on every tab leads to many duplicated requests and race conditions between them:
This means I needed to find a way to ensure that, across all tabs in the browser, my function inside the service worker runs only once, even though each tab receives the same message.
Brute force solutions
After initial research, I found several potential solutions that were looking pretty good:
Using document visibility
This approach is next: use document.hidden to check if the current tab is visible, and if it is, run the function in service worker:
Pros:
-
Easy to think about.
-
Works well when the user sits on our website.
Cons:
-
The problem when you switch to other websites: If the user switches to another website when receiving a WebSocket message, no tab will run the script until he returns to the website.
-
Queue management: You need a queue to hold tasks while the user isn’t focused on the platform tab, which adds some complexity.
Shared Worker
The next idea was to use a SharedWorker API which can preserve a state between the open tabs. What you need to do is just to maintain a list of active tabs and send a run event from only one of them:
Pros:
-
One shared worker across tabs, we can track the state of tabs and run a background job on only one of them.
-
Works well even if you sit on another website.
Cons:
-
Tab closure issue: It’s not possible to track if tabs are closed. Doing so would require adding heartbeat functionality, which complicates the algorithm and introduces numerous edge cases. Unfortunately, this limitation exists primarily for security reasons. You can read more about it here: https://github.com/whatwg/html/issues/1766
-
Risk of lost tasks: If a tab is closed without notifying the shared worker (due to the limitation mentioned above), it can result in lost tasks.
As you can see, we have two brute-force solutions that fail to cover some cases.
P.S. I also do not mention any type of storage like localStorage, sessionStorage and indexDB because they do not solve the problem of race conditions at all.
LockManager API
After searching a while through the internet I found an interesting native Web API called LockManager API. From the description, it ensures a task runs exclusively by locking it until completion.
If you take a closer look at the description, that’s exactly what I need. My task is to ensure that a service worker function runs only from one tab at a time within a given period.
Problem solution
After reviewing the functionality of the API, I created a function with a name that implies it locks a process between tabs:
const lockProcessAcrossTabs = <P extends Promise<unknown>>(name: string, process: () => P) => {
navigator.locks.request(name, { ifAvailable: true }, lock => {
if (!lock) return
return process()
})
}
and used it in my production codebase like this:
const client = new APIClient()
const serviceWorkerFunction = (data: MessageData) => {
return chrome.runtime.sendMessage({ message: JSON.stringify(data) })
}
subscription({
client,
onData: (data: SubscriptionData) => {
lockProcessAcrossTabs("serviceWorkerFunction", async () => {
const result = await serviceWorkerFunction(data))
// send result to the server
})
}
})
Thus, I was able to solve the race condition problem between tabs elegantly and simply by executing and locking the request exclusively on a single browser page.
A small note about the LockManager API request ifAvailable option:
If true, the lock request will only be granted if it is not already held. If it cannot be granted, the callback will be invoked with null instead of a Lock instance. The default value is false.
With this option set to true I check if there is a lock instance, and if not, it means the process is running elsewhere in a browser context.
Browser support
ManagerLock API is a relatively new API that does not work in older browsers:
For example, support in the Safari browser starts only from 15.4 — a relatively new version.
To make sure that it’s working in all browsers, I suggest using polyfill which can cover you if its native API is not available: https://github.com/aermin/web-locks. It’s also great to check how this polyfill is implemented and works under the hood.
I hope you enjoyed this article and learned something new!