When the same origin can start the same short, side‑effecting job from more than one JavaScript context — two tabs, a tab plus a worker, or multiple workers — an in‑memory mutex is already too small. Each context has its own heap and its own copy of any boolean, promise, or singleton, so module‑level guards only stop reentry inside a single context.
Use navigator.locks.request() when you need the browser to coordinate ownership across contexts. The API takes a lock name, options (mode: "exclusive" or "shared", ifAvailable, steal), and a callback. The browser grants the lock and calls your callback with a Lock object. When the callback returns or its promise settles, the browser releases the lock and advances the queue. That queueing is the key difference from local flags: a second requester waits its turn instead of guessing whether the resource is free.
Expected behavior: exclusive mode gives single‑holder ownership; shared mode allows concurrent readers but blocks writers until readers finish; ifAvailable lets you probe without waiting (returns null when busy); steal forces immediate takeover (use cautiously). The API is available only in secure contexts and is origin‑scoped; WorkerNavigator exposes locks inside workers too.
Implementation pattern (server will insert the verified example): do cheap validation and idempotent preparation outside request(), keep the critical section short and focused on unsafe mutations inside the callback, and record progress durably in storage so you can recover from partial work.
Limitations: The lock protects overlapping callbacks but does not provide transactional rollback. If your callback writes multiple records, you still need retry/reconciliation and storage‑level recovery. The article’s shim and tests demonstrate queueing and ordering deterministically for development but do not simulate browser process restarts or tab crashes; design durable recovery when jobs can leave partial state.
Use a local mutex when the race is confined to one JS context. Use navigator.locks.request() when you need cross‑context serialization and you want the browser to own the queue. The full article walks through the mental models, the shim behavior, and concrete examples for structuring the critical section.
Working example
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 };
Read the full article: https://chriseugenerodriguez.com/blog/when-to-use-lockmanager-request-across-tabs
Top comments (0)