When should I use LockManager.request() to serialize work across tabs instead of in-memory locks?
Use LockManager.request() when same-origin tabs or workers need browser-coordinated mutual exclusion. Learn why in-memory locks stop at one JavaScript heap, how exclusive and shared requests behave, and what the callback boundary does and does not guarantee.
If two tabs can start the same job, a local mutex is already too small. The question is not whether you can guard a function call inside one JavaScript heap; it is whether the browser can coordinate ownership across same-origin windows and workers without your app inventing a fragile protocol.
`navigator.locks.request()` is the browser’s documented answer. It gives you a named lock, an asynchronous callback that runs while the lock is held, and automatic release when the callback completes. MDN also documents secure-context requirements, worker support, and the exclusive/shared modes that make the API useful for cross-context serialization.
In-memory locks stop at the JavaScript context boundary
A local mutex works only as long as every contender runs in the same JavaScript heap. That is a narrower world than many web apps actually live in. Once the same origin can run in two tabs, a tab and a worker, or two workers, each context has its own memory and its own copy of any flag, promise, or singleton you create. A module-level boolean can stop reentry inside one context, but it cannot stop another context from starting the same job because the other context never sees that boolean at all.
That is the first reason to reach for the Web Locks API instead of an in-memory lock. The browser, not your application object graph, becomes the coordinator across contexts. MDN describes Web Locks as a way for scripts running in one tab or worker to asynchronously acquire a lock, hold it while work is performed, and release it automatically afterward. The resource is named by your app, but the arbitration happens at the browser level for the origin.
This distinction matters when the unsafe work is side-effecting and short enough to be modeled as a critical section. Syncing a cache, refreshing an index, writing a migration marker, or updating shared state are all examples where two overlapping starts can create duplicated writes or bad ordering. In those cases, a local lock is at best a partial defense. It can reduce races inside one context, but it cannot serialize the same origin as a whole.
class Deferred {
constructor() {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
function normalizeRequestArgs(optionsOrCallback, maybeCallback) {
if (typeof optionsOrCallback === 'function') {
return { options: {}, callback: optionsOrCallback };
}
return { options: optionsOrCallback ?? {}, callback: maybeCallback };
}
export function createInMemoryLockManager() {
const state = new Map();
let nextToken = 1;
function getEntry(name) {
if (!state.has(name)) {
state.set(name, { holders: [], queue: [] });
}
return state.get(name);
}
function canGrant(entry, mode) {
if (entry.holders.length === 0) return true;
if (mode === 'shared' && entry.holders.every((h) => h.mode === 'shared')) return true;
return false;
}
function scheduleFlush(name) {
queueMicrotask(() => flush(name));
}
function grant(entry, name, request) {
entry.holders.push({ token: request.token, mode: request.mode });
const finish = async () => {
try {
const result = await request.callback({ name, mode: request.mode });
request.deferred.resolve(result);
} catch (error) {
request.deferred.reject(error);
} finally {
entry.holders = entry.holders.filter((holder) => holder.token !== request.token);
scheduleFlush(name);
}
};
finish();
}
function flush(name) {
const entry = getEntry(name);
while (entry.queue.length > 0 && canGrant(entry, entry.queue[0].mode)) {
const request = entry.queue.shift();
grant(entry, name, request);
if (request.mode === 'exclusive') break;
}
}
async function request(name, optionsOrCallback, maybeCallback) {
const { options, callback } = normalizeRequestArgs(optionsOrCallback, maybeCallback);
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function');
}
const mode = options.mode ?? 'exclusive';
const ifAvailable = Boolean(options.ifAvailable);
const steal = Boolean(options.steal);
if (ifAvailable && steal) {
throw new Error('NotSupportedError: ifAvailable and steal cannot both be true');
}
if (mode !== 'exclusive' && mode !== 'shared') {
throw new Error('mode must be "exclusive" or "shared"');
}
const entry = getEntry(name);
const token = nextToken++;
if (steal) {
entry.holders.length = 0;
entry.queue.length = 0;
}
if (ifAvailable && !canGrant(entry, mode)) {
return callback(null);
}
if (canGrant(entry, mode)) {
const deferred = new Deferred();
const requestRecord = { token, mode, callback, deferred };
grant(entry, name, requestRecord);
return deferred.promise;
}
const deferred = new Deferred();
entry.queue.push({ token, mode, callback, deferred });
return deferred.promise;
}
return { request };
}
export async function runExclusiveTask(lockManager, lockName, work) {
return lockManager.request(lockName, { mode: 'exclusive' }, async (lock) => work(lock));
}
export async function runSharedTask(lockManager, lockName, work) {
return lockManager.request(lockName, { mode: 'shared' }, async (lock) => work(lock));
}
export { Deferred }; SourcesMDN: Web Locks API (opens a new tab)MDN: LockManager.request() (opens a new tab)
LockManager.request() gives you browser-managed queueing, not just a shared variable
`navigator.locks.request()` is the entry point you use when you want the browser to decide who owns a named lock. You pass a lock name, optional options, and a callback. When the lock is granted, the browser invokes the callback with a `Lock` object. When the callback returns, or the returned promise settles, the lock is released. That shape makes the protected region explicit: the work inside the callback is the work that must not overlap.
The browser also queues later requests for the same name. That queueing is the practical difference from a boolean flag or a custom promise chain. If one context already holds the lock, another same-name request waits its turn instead of guessing whether the resource is free. MDN also documents `mode: "exclusive"` and `mode: "shared"`. Exclusive mode means only one holder at a time. Shared mode means multiple readers can coexist, while an exclusive writer must wait until the shared holders leave.
That readers-writer split is useful when the operation is not purely write-only. A reader that only inspects a shared cache may be safe to overlap with other readers, while a writer that mutates the same cache needs to wait. The important point is that `request()` is not just a callback wrapper. It is a browser-level ownership protocol with queueing semantics that your own in-memory lock cannot provide across tabs or workers.
The documented boundary is secure context, workers, and origin scope
There are three practical limits to keep in mind before you design around Web Locks. First, the API is available only in secure contexts. That means you should expect it on HTTPS pages, not on arbitrary insecure origins. Second, MDN documents support in Web Workers, and the interface is exposed through `WorkerNavigator.locks` as well as `navigator.locks`. So the coordination primitive is not limited to the main window thread.
Third, the scope is origin, not page. Two same-origin tabs participate in the same lock namespace. Two different origins do not. This is exactly why the API is useful for cross-tab serialization without a custom broker or a broadcast-channel protocol. A lock name is just an application-chosen string; the browser enforces it within the origin’s lock manager.
That origin boundary also explains when you should not overuse the API. If the resource is private to one component instance or one tab, the browser is probably unnecessary overhead. If the resource crosses tabs or workers, a local mutex is too small. Web Locks fills the middle ground where you need coordination, but you do not want to build your own arbitration service just to protect a short critical section.
SourcesMDN: Web Locks API (opens a new tab)MDN: LockManager.request() (opens a new tab)
A local mutex or storage flag is an incomplete fix
A common reaction to cross-tab races is to add a flag in storage, or to debounce the trigger in each tab, and then call the problem solved. Those partial fixes can reduce frequency, but they do not change ownership. Two contexts can still observe the same state before either one updates it. Two debounced triggers can still overlap after the delay window. The result is timing improvement, not serialization.
A storage-backed flag is also awkward because it mixes durable state with live coordination. If you use the same persistence layer for both, you now need a compare-and-swap style protocol just to decide which context may begin. That protocol can work, but it is more complex than asking the browser for a lock. The Web Locks API exists precisely to provide the live coordination primitive directly.
The better rule is simple: use a local mutex when the race lives entirely inside one JavaScript context. Use `navigator.locks.request()` when the same origin can execute the same operation in more than one context. Put the contested branch inside the callback, let the browser queue the rest, and keep durability concerns in the storage layer that actually owns the data.
This separation is why the API is especially useful for background work. A worker can coordinate with a tab, a tab can coordinate with another tab, and neither one has to know which context started first. The browser owns the queue. Your code owns the data model and the recovery path.
The provided shim proves ordering and queueing, but not live browser behavior
The supplied implementation uses an injected in-memory lock manager so the example can be tested deterministically. That shim is useful because it models the public shape of `request()`: exclusive and shared modes, `ifAvailable`, `steal`, queueing, and automatic release after the callback finishes. The tests then exercise the helper functions that call into that shim. One test verifies that two exclusive requests serialize. Another verifies that `ifAvailable` returns `null` when the lock is already held. A third verifies that shared holders can overlap and that an exclusive waiter runs only after the shared holders release.
That evidence is real, but its scope is narrow. The tests prove the behavior of the injected shim and the helper functions that use it. They do not prove that a specific browser build behaves the same way under every edge case, because a Node test runner is not a browser conformance suite. The browser-level guarantees come from the MDN-documented API contract, while the tests show how to structure a critical section and how to observe queueing deterministically in the sample code.
The tests are still valuable because they force the article’s central claim to stay concrete. An exclusive request cannot enter before the first exclusive holder finishes. A shared request can overlap with another shared request. An immediate probe can fail fast when a lock is busy. Those are the behaviors a reader needs to recognize before deciding whether their own application should use `navigator.locks.request()`.
The important negative case is also visible: this fixture does not pretend to simulate tab crashes, browser process restarts, or full production coordination across a real origin. It demonstrates the lock boundary and queue behavior. The reader still has to use the actual browser API in their application and design storage recovery separately if a job can leave durable partial work behind.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInMemoryLockManager, runExclusiveTask, runSharedTask, Deferred } from './main.mjs';
test('exclusive tasks serialize across queued requests', async () => {
const locks = createInMemoryLockManager();
const order = [];
const firstEntered = new Deferred();
const releaseFirst = new Deferred();
const first = runExclusiveTask(locks, 'sync-index', async () => {
order.push('first-enter');
firstEntered.resolve();
await releaseFirst.promise;
order.push('first-leave');
return 'first-result';
});
await firstEntered.promise;
const second = runExclusiveTask(locks, 'sync-index', async () => {
order.push('second-enter');
order.push('second-leave');
return 'second-result';
});
releaseFirst.resolve();
assert.equal(await first, 'first-result');
assert.equal(await second, 'second-result');
assert.deepEqual(order, ['first-enter', 'first-leave', 'second-enter', 'second-leave']);
});
test('ifAvailable returns null when the lock is already held', async () => {
const locks = createInMemoryLockManager();
const release = new Deferred();
const holder = runExclusiveTask(locks, 'sync-index', async () => release.promise);
await Promise.resolve();
const observed = [];
const result = await locks.request('sync-index', { ifAvailable: true }, async (lock) => {
observed.push(lock);
return lock;
});
release.resolve();
await holder;
assert.equal(result, null);
assert.deepEqual(observed, [null]);
});
test('invalid option combination throws before any work starts', async () => {
const locks = createInMemoryLockManager();
let called = false;
await assert.rejects(
locks.request('sync-index', { ifAvailable: true, steal: true }, async () => {
called = true;
}),
/NotSupportedError/,
);
assert.equal(called, false);
});
test('shared locks can overlap and then release before an exclusive waiter runs', async () => {
const locks = createInMemoryLockManager();
const steps = [];
const bothInside = new Deferred();
const continueShared = new Deferred();
const reader1 = runSharedTask(locks, 'documents', async () => {
steps.push('reader1-enter');
bothInside.resolve();
await continueShared.promise;
steps.push('reader1-leave');
return 'r1';
});
const reader2 = runSharedTask(locks, 'documents', async () => {
steps.push('reader2-enter');
await continueShared.promise;
steps.push('reader2-leave');
return 'r2';
});
await bothInside.promise;
assert.deepEqual(steps, ['reader1-enter', 'reader2-enter']);
const writer = runExclusiveTask(locks, 'documents', async () => {
steps.push('writer-enter');
steps.push('writer-leave');
return 'w';
});
continueShared.resolve();
assert.equal(await reader1, 'r1');
assert.equal(await reader2, 'r2');
assert.equal(await writer, 'w');
assert.deepEqual(steps, [
'reader1-enter',
'reader2-enter',
'reader1-leave',
'reader2-leave',
'writer-enter',
'writer-leave',
]);
});


