Cross-tab Synchronization with the Web Locks API

By on August 14, 2018 10:03 am

two people inserting large key into keyhole

The Web Locks API is a new addition to the Web Platform which allows you to execute JavaScript in a lock, a resource which can potentially get shared with other browser tabs. This API is currently available in Chrome and other Chromium-based browsers with no major signals from other browser vendors.

Use cases which are suitable for the Web Locks API are the managing, coordinating, and syncing of multi-tab interactions. Currently, some or all of the Shared Web Worker, Broadcast Channel, Local Storage, Session Storage, Post Message & unload handler APIs can be used to manage tab communication and synchronization, however, they each have their drawbacks and require workarounds which decrease code maintainability.

Many simple web applications do not have a use case for the Web Locks API, however more complex, JavaScript-heavy applications may have such a use case. Two brief use cases include:

  1. A user opens up two tabs of the same text editing web application in their browser. Changes to the document need to get synced back to the server. Complications can arise if tab #2 syncs a change back to the server which clashes with tab #1. The Web Lock API enables multiple instances of the same web application to gain exclusivity with executing a JavaScript function.
  2. A user leaves a tab open for a stock portfolio investment web application. It’s not unheard of that one may leave such a tab open throughout the day so they can scan the current stock price. The user opens up a new tab of the same web app to purchase some shares. Both tabs may need to coordinate with each other, so the web user interface doesn’t mistakenly allow duplicate transactions to go through, one in each tab.

When an exclusive lock gets held in one browser tab, the same JavaScript from another tab, even it matches the same origin, cannot acquire this lock. When the primary instance of a web application releases its lock, other instances of the same web app may request access to the same lock throughout their lifecycle. For a lock to be released, it’s said to have completed its function execution, or in some cases, errored during execution.

There are at least three crucial concepts to understand the basics of the Web Locks API:

  • lock request – this is where you, the web developer starts
  • lock – something you may acquire after a lock request gets granted
  • lock manager – an overview of held locks and pending locks

There are many concepts part of the Web Locks API. However, we’ll focus on the above three topics in this post.

Tip: Locks get entirely isolated in incognito and private browsing environments which helps protect the privacy of the user.

Lock Request

Lock requests are placed into a queue and granted by the browser if:

  • No other lock gets held which matches the same name
  • No other pending lock requests are waiting to be granted and made earlier in the queue

You can request a lock using the request method of the locks object:

navigator.locks.request('your-lock', async lock => {
	return 'Done!'
});

The first argument to the request method is a string-based identifier which should be something meaningful to your web app. The callback function receives a lock as its argument, a simple object which may look like this:

{
	mode: "exclusive"
	name: "your-lock"
}

Notice the callback function passed to the request method is an anonymous async function. The Web Locks API is Promise-aware and waits for the Promise based callback function to settle before it releases a lock.

You can abort a lock request using the AbortController API. You may wish to abort a lock request based on your application state changing. When you request a lock, it may not get acquired in an acceptable timeframe. One use case for aborting a lock request is to cancel it if it does not get acquired within a certain timeframe:

// The Web Locks API waits for this promise to settle
// By not calling resolve, we keep 'lock-1' active
function holdTheLock() {
	// This Promise is never resolved
	return new Promise(resolve => {});
}

navigator.locks.request('lock-1', holdTheLock);

const controller = new AbortController();

// If the lock isn't acquired within one second, then abort it
setTimeout(() => controller.abort(), 1000);

// Tip: This is similar to cancellation in the Fetch API 
const lockOptions = {
	signal: controller.signal
};

function neverExecuted () {
	console.log('I will never log to the console');
}

// If you have lock options, pass it as the 2nd argument
// Note, the already held ‘lock-1’ has not been released
navigator.locks.request('lock-1', lockOptions, neverExecuted);

After one second, the following gets logged to the console:

Uncaught (in promise) DOMException: The request was aborted.

If you have worked with promises before, you may be familiar with a JavaScript error following the pattern: “Uncaught (in promise).” The solution is to handle the error, for example with the following code:

try {
	await navigator.locks.request('lock-1', lockOptions, neverExecuted);
} catch (err) {
	console.log('Error: Could not request a lock', err);
}

The navigator.locks.request() method returns a promise which you can await, using the await keyword. An alternative pattern, if you’d rather avoid the try/catch blocks, is to tack on a catch() callback:

navigator.locks.request('lock-1', lockOptions, neverExecuted)
	.catch(err => console.log('Error: Could not request a lock', err));

Another available configuration option when requesting a lock is the ifAvailable flag. Observe the following code:

const lockOptions = {ifAvailable: true};
navigator.locks.request('resource', lockOptions, () => {});

The default value for the ifAvailable flag is false. If the value gets set to true, the lock gets granted only if the following conditions get met:

  • The usual conditions from granting locks must still get met
  • If the lock can get granted without additional waiting, then grant the lock. Otherwise, if waiting time is required, the lock request is discarded from the lock request queue.

Lock Acquisition

When you acquire the lock named your-lock, at some point, it gets released. The lock release could happen when:

  • The async callback function which gets passed to the lock request has its promise settled
  • When the non-async callback function which gets passed in has finished executing
  • When the tab closes/crashes

If you have acquired an exclusive lock, you can trust that no other browser tab running the same codebase may execute the same code, at the same time, under the same named lock. However, note that lock requests may be queued up in other tabs and those queued lock requests can eventually be granted based on conditions in or out of your control:

  • In your control: Your JavaScript code releases the lock because script execution has finished
  • Out of your control: Your user closes one tab instance of your web application

When you request a lock, you also pass in a callback function which gets invoked upon lock acquisition. You can return a value from this callback function. Observe the following techniques:

Technique #1: Pass in an async function which returns a string

await navigator.locks.request('lock-1', async () => 'hello'); // logs ‘hello’

Technique #2: Pass in a promise which explicitly resolves to a string

await navigator.locks.request('lock-1', () => new Promise(resolve => resolve('hello'))); // logs ‘hello’

Technique #3: Pass in a non-promise based function which still returns a string

await navigator.locks.request('lock-1', () => 'hello'); // logs ‘hello’

You may be wondering how the above line of code works (technique #3) since the callback function does not return a promise or use an async-based function. This technique works because the Lock API itself wraps non-promise based functions with a promise. The return value of the callback function is used to resolve the navigator.locks.request() promise.

Shared Locks

By default, locks are exclusive, meaning another script cannot obtain access until the lock gets released. Another script cannot obtain access to an unreleased exclusive acquired lock. When you request a lock, you can permit sharing capabilities:

const lockOptions = {mode: 'shared'};
navigator.locks.request('resource', lockOptions, () => {});

A shared lock is one of two types of locks you can request. When you hold a shared lock, other scripts can request a lock of the same name. Shared locks can allow you to better coordinate workloads across a certain number of tabs which belong to the same web application. Shared locks can also get used when working with a database, such as IndexedDB. Obtaining multiple shared reader locks, but only a single exclusive writer lock, might be a useful pattern.

Lock Manager

A lock manager organizes the internal state of all locks and gets controlled by the browser. If you create a few lock requests and also acquire a few locks, you can execute the following code to query for all locks and lock requests.

await navigator.locks.query()

The query() function call returns an object containing an array of held locks and an array of pending locks which are in a queue. The query() method returns the following JSON structure:

{
  "held": [{
      "clientId": "a58e9...",
      "mode": "exclusive",
      "name": "lock-1"
    }, {
      "clientId": "a58e9...",
      "mode": "exclusive",
      "name": "lock-2"
    }, {
      "clientId": "a58e9...",
      "mode": "exclusive",
      "name": "lock-3"
  }],
  "pending": [{
      "clientId": "a58e9...",
      "mode": "exclusive",
      "name": "lock-1"
    }, {
      "clientId": "a58e9...",
      "mode": "exclusive",
      "name": "lock-1"
  }]
}

Notice in the JSON object above, an array of locks, pending or held, gets provided. We have covered the mode and name fields of a lock object. The third field, the clientId is a string which uniquely identifies the user agent environment, and gets provided by the browser.

Querying the state of all locks can help with diagnostic debugging. You could also query the lock manager to make decisions on how subsequent lock requests should get made, and how long acquired locks may get held.

Conclusion

To recap the normal flow which you as a developer go through when dealing with the Web Locks API:

  1. First, you create a lock request with navigator.locks.request. Recall that each lock has an identifier. A lock request which matches the name of an already held lock does not get granted until the held lock is released.
  2. At some point, you acquire the lock you requested in step #1 and execute the callback code
  3. As an optional step, you can query for the state of all locks with the query() method

Tip: Utilizing the Web Locks API in your application does not guarantee the integrity or reliability of operations. For example, if you are dealing with network requests of financial transactions, you must always validate and approve such requests on the server-side. Where the Web Locks API can shine is when updating the user interface to explain to the user, for example: “You have already submitted this withdrawal request in another browser tab. That request is still pending, please wait.”

Web locks help manage your client-side application state and provide a more reliable user experience.

If you want to use the Web Locks API in an unsupported browser, there was a Web Locks polyfill in the official Web Locks API repository on GitHub. Unfortunately, this has been removed for now but hopefully a replacement polyfill will arrive soon. Alternatively, if you’re looking for another solution to achieve cross-tab communication, check out tabex on GitHub.

Next steps

Let's Talk! Logo

Are you looking for help building applications that leverage modern best practices and features such as the Web Locks API? Contact us to discuss how we can help!