Web Locks API: Synchronize Resources Across Tabs

The Web Locks API is a built-in browser standard that allows JavaScript code running across multiple browsing contexts—such as different tabs, windows, iframes, or Web Workers—to coordinate access to shared resources. This article explains the core concepts of the Web Locks API, compares exclusive and shared lock modes, explores key options like non-blocking requests, and demonstrates practical code implementations to prevent race conditions and manage shared state effectively.

The Multi-Tab Concurrency Problem

Modern web applications often run concurrently across multiple browser tabs. When these tabs read from and write to shared storage mechanisms like IndexedDB, localStorage, or Cache Storage at the same time, race conditions can occur. Traditional JavaScript execution is single-threaded per context, but across multiple contexts, operations interleave unpredictably.

Common issues include: - Concurrent read-modify-write operations overwriting data in IndexedDB. - Multiple tabs simultaneously attempting to refresh an expiring authentication token. - Redundant background network requests or duplicate WebSocket connections.

The Web Locks API solves these issues by providing a standardized mutual exclusion mechanism.

How the Web Locks API Works

The API operates under navigator.locks. It allows an execution context to request a named lock. The lock is held for the duration of an asynchronous callback function passed to navigator.locks.request(). Once the returned Promise resolves or rejects, the browser automatically releases the lock for waiting contexts.

Basic Exclusive Lock

By default, locks are exclusive. Only one context can hold an exclusive lock with a given name at any time.

async function updateSharedData(newData) {
  await navigator.locks.request('database_sync_lock', async (lock) => {
    // Critical section: Only one tab executes this block at a time
    const currentData = await readFromIndexedDB();
    const updatedData = { ...currentData, ...newData };
    await writeToIndexedDB(updatedData);
    console.log('Data successfully updated by the lock holder.');
  });
  // The lock is automatically released here
}

If Tab A is executing the callback, Tab B will pause at navigator.locks.request('database_sync_lock', ...) until Tab A finishes.

Lock Modes: Exclusive vs. Shared

The API supports two distinct modes configured via the mode option:

  1. exclusive (Default): Grants access to only one requester. All other requests (exclusive or shared) must wait in a FIFO queue.
  2. shared: Allows multiple contexts to acquire the lock concurrently, provided no exclusive lock on the same resource is active. This mirrors the reader-writer pattern, making it ideal for read-only tasks that should not run alongside writes.
// Reader (Shared)
async function readData() {
  await navigator.locks.request('resource_name', { mode: 'shared' }, async (lock) => {
    return await readFromStorage();
  });
}

// Writer (Exclusive)
async function writeData(data) {
  await navigator.locks.request('resource_name', { mode: 'exclusive' }, async (lock) => {
    await writeToStorage(data);
  });
}

Advanced Request Options

Non-Blocking Requests (ifAvailable)

By default, lock requests queue until the resource is free. Passing { ifAvailable: true } instructs the browser to evaluate the lock immediately without queueing. If the lock is held elsewhere, the callback executes with null instead of a lock object.

await navigator.locks.request('leader_election', { ifAvailable: true }, async (lock) => {
  if (!lock) {
    console.log('Another tab is already the leader.');
    return;
  }
  
  console.log('This tab is now the designated leader.');
  // Keep the lock active to maintain leadership
  await keepAlive();
});

Abort Signals and Timeouts

Lock requests can be cancelled while waiting in the queue using standard AbortController signals.

const controller = new AbortController();
setTimeout(() => controller.abort(), 3000); // 3-second timeout

try {
  await navigator.locks.request('auth_token_refresh', { signal: controller.signal }, async (lock) => {
    await refreshToken();
  });
} catch (err) {
  if (err.name === 'AbortError') {
    console.warn('Failed to acquire lock before the timeout.');
  }
}

Inspecting Lock State

The API provides navigator.locks.query() to inspect active and pending locks across the origin. This is useful for diagnostics, debugging, and metrics.

const state = await navigator.locks.query();
console.log('Held locks:', state.held);
console.log('Pending requests:', state.pending);

Primary Use Cases