A browser app is not one process with one state. A user can open the same site in two tabs, restore a session after a crash, or leave a worker alive while a page is still active. If each context independently flushes an offline queue, refreshes credentials, or runs a database migration, “just make the function idempotent” is often necessary—but it is not always sufficient.
The Web Locks API gives same-origin code a small coordination primitive: ask for a named lock, do asynchronous work while it is held, and let the browser release it when the callback settles. It is not a distributed lock and it does not replace server-side authorization. It is a way for cooperating contexts in one browser profile to avoid duplicating local work.
The problem: one app, several contenders
Consider an application with an IndexedDB-backed outbox. Every open tab tries to send pending items when the connection returns. Without coordination, two tabs can select the same record before either marks it sent. A server-side idempotency key should still protect the remote mutation, but duplicate local attempts waste work and complicate UI state.
A naive boolean in localStorage has weak failure behavior: a crashed tab may never clear it, and hand-rolled polling has race conditions. BroadcastChannel is useful for messaging, but it does not by itself grant exclusive ownership. Web Locks directly models the resource that needs one owner.
A single-flight outbox flusher
Use a stable, namespaced lock name. The name is meaningful only inside your application, so treat it as an internal protocol and avoid accidental collisions.
async function flushOutbox() {
if (!("locks" in navigator)) {
// Fall back to server idempotency and best-effort local behavior.
return flushOutboxWithoutCrossTabLock();
}
return navigator.locks.request(
"acme:outbox-flush:v1",
{ ifAvailable: true },
async (lock) => {
if (!lock) {
// Another same-origin context is already flushing.
return { started: false };
}
const pending = await readPendingOutboxItems();
for (const item of pending) {
// The server must still validate the caller and deduplicate mutations.
await sendItem(item, { idempotencyKey: item.id });
await markSent(item.id);
}
return { started: true, sent: pending.length };
}
);
}
ifAvailable: true makes this a non-waiting attempt. If another context holds the exclusive lock, the callback receives null; it is not an exception. This is a good fit for a periodic or reconnect-triggered flush, where waiting behind another tab would add no value.
The default mode is exclusive. That matters: an exclusive lock with the same name prevents any other lock of that name from being granted. If the callback returns or throws, the lock is released. Keep the whole critical sequence inside the callback and await it; returning early releases the lock early too.
Choose the lock boundary, not the biggest possible lock
A lock is a coordination boundary, not a substitute for architecture. Holding it while a user completes a form or while a long upload runs turns unrelated tabs into a queue. Prefer a narrow unit of work: claim one batch, atomically update the local store, or elect a leader for one short interval.
For a task that may wait a long time, give up rather than accumulating a queue:
async function refreshCatalog() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1_500);
try {
return await navigator.locks.request(
"acme:catalog-refresh:v1",
{ signal: controller.signal },
async () => {
const response = await fetch("/api/catalog", {
credentials: "same-origin"
});
if (!response.ok) throw new Error("Catalog refresh failed");
await cacheCatalog(await response.json());
}
);
} finally {
clearTimeout(timeout);
}
}
The signal cancels a request only before it is granted. Once the callback has a lock, cancellation of that request no longer interrupts the holder. Design the work itself with normal timeouts, cancellation, and transaction boundaries.
Shared reads and exclusive writes
The API also supports mode: "shared". Several contexts may hold a shared lock for one name, but an exclusive lock for that name will wait until those readers release it. This resembles a readers-writer pattern.
That option is useful only when your operations really have separate read and write semantics. It does not make arbitrary browser storage reads consistent, and it does not coordinate contexts from a different origin. Adding shared locks “for performance” without a concrete invariant is usually needless complexity.
Failure and security boundaries
Web Locks is cooperative. Any script running on your origin can request the same name, so do not use it as a security control. It cannot authenticate a user, protect an API endpoint, or coordinate another device. The server remains the authority for permissions, replay protection, and idempotency.
Avoid steal: true in normal flows. The specification allows it as a recovery escape hatch, but the previous holder can still be running code that assumes exclusive access. That can break exactly the invariant the lock was meant to provide. Use it only after designing the recovery protocol and making every operation safe under overlap.
There are also browser-boundary limits. Separate browser profiles and private sessions are separate user agents, so they do not share this coordination state. Feature-detect navigator.locks and keep your correctness guarantees valid without it. Your fallback might be server idempotency plus an IndexedDB transaction, or simply a less efficient best-effort retry.
A practical checklist
- Name locks by the invariant:
acme:outbox-flush:v1, notlock1. - Keep the callback small and await all protected asynchronous work.
- Use
ifAvailablefor opportunistic jobs; use a signal when waiting is acceptable but bounded. - Keep server-side validation and idempotency even when a lock is held.
- Treat
stealas fault recovery, not routine contention handling. - Test two tabs, a reload, an offline-to-online transition, and the no-Web-Locks fallback.
Web Locks will not turn a browser into a distributed transaction system. It does give a precise, browser-native answer to a common local question: which same-origin context should do this work right now?
A note on testing
Test the behavior in a real same-origin pair of tabs, but do not claim that one local test proves compatibility or correctness everywhere. The important observation is simpler: only the tab whose callback receives a non-null lock should begin the protected work. Record that event in development, then verify the server still rejects duplicates independently.
Top comments (0)