← Back to DevBytes

Implementing Web Locks API in Modern Web Applications

What is the Web Locks API?

The Web Locks API is a browser-native mechanism that allows web applications to acquire and release locks asynchronously, ensuring coordinated access to shared resources across multiple execution contexts. It provides a way to prevent race conditions when multiple tabs, windows, or workers within the same origin need to operate on the same data, such as IndexedDB databases, localStorage, or shared memory.

At its core, the API exposes a global LockManager interface via navigator.locks, enabling developers to request named locks. These locks can be exclusive (only one holder at a time) or shared (multiple readers simultaneously), and they are automatically released when the work is done, even if an error occurs. The API is designed around the concept of a lock scope—a callback or async function that executes while the lock is held—ensuring that locks are never left dangling.

Why Does It Matter?

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Modern web applications often run in multiple contexts: a user might open several tabs of the same application, or the app may have service workers, web workers, and the main thread all competing for shared resources. Without a locking mechanism, concurrent operations can lead to:

The Web Locks API provides a standardized, lightweight way to synchronize these operations without relying on complex message-passing hacks or fragile polling mechanisms. It integrates seamlessly with promises and async/await, making it straightforward to incorporate into modern JavaScript codebases.

Core Concepts and API Overview

The LockManager Interface

Access the LockManager through navigator.locks. It offers three primary methods:

Requesting a Lock

The simplest usage is to request an exclusive lock on a given name. The callback receives a lock object (which can be passed to abort controllers) and returns a promise or a value. The lock is held until the callback's promise settles.


navigator.locks.request('my-resource', async (lock) => {
  console.log('Lock acquired:', lock.name);
  // Perform critical work
  await doSomething();
  // Lock automatically released after this callback completes
});

Lock Options and Modes

The second argument to request() can be an options object:


navigator.locks.request('database-write', { mode: 'exclusive', ifAvailable: true, signal: AbortSignal.timeout(5000) }, async (lock) => {
  if (lock === null) {
    console.log('Lock not available, skipping.');
    return;
  }
  // Critical section
});

Using Abort Controllers for Timeouts

Because lock requests are queued by default, a request may wait indefinitely if another client holds the lock. To avoid blocking the UI or causing deadlocks, always provide an abort signal with a timeout. You can use AbortSignal.timeout() (modern browsers) or manually create an AbortController.


const controller = new AbortController();
setTimeout(() => controller.abort(), 3000); // Abort after 3 seconds

try {
  await navigator.locks.request('critical-path', { signal: controller.signal }, async () => {
    // ...
  });
} catch (err) {
  if (err.name === 'AbortError') {
    console.warn('Lock request timed out.');
  }
}

Asynchronous Lock Work

The callback must return a promise to keep the lock alive during asynchronous operations. If you return a non-promise value, the lock is released immediately. The lock object passed to the callback is a snapshot of the lock's name and mode; it doesn't provide any control methods.

Always await inside the callback or return a promise that encompasses all your async work.

Practical Examples

Example 1: Synchronizing Shared State Across Tabs

Suppose you have a shared counter stored in localStorage, and multiple tabs can increment it. Without locking, two tabs reading and writing simultaneously could miss increments. Using an exclusive lock ensures atomic read-modify-write.


async function incrementSharedCounter() {
  await navigator.locks.request('counter-lock', { mode: 'exclusive' }, async () => {
    const current = parseInt(localStorage.getItem('shared-counter') || '0', 10);
    const next = current + 1;
    // Simulate some async work (e.g., server sync)
    await new Promise(resolve => setTimeout(resolve, 100));
    localStorage.setItem('shared-counter', next.toString());
    console.log('Counter incremented to', next);
  });
}

// Call this from any tab
incrementSharedCounter();

Example 2: Preventing Race Conditions in IndexedDB

When multiple tabs write to the same object store, transactions can overlap in ways that cause conflicts. The Web Locks API can serialize these writes, ensuring only one tab performs a write operation at a time.


async function saveDocument(docId, content) {
  await navigator.locks.request(`doc-${docId}`, { mode: 'exclusive' }, async () => {
    const db = await openDatabase(); // your helper
    const tx = db.transaction('documents', 'readwrite');
    const store = tx.objectStore('documents');
    store.put({ id: docId, content, updated: Date.now() });
    await tx.done;
    console.log('Document saved safely');
  });
}

Example 3: Sequential Resource Loading

Imagine you want to load a large resource (e.g., a WASM module) only once, even if multiple tabs request it simultaneously. You can use a shared lock with a check-and-set pattern, but a more reliable approach is an exclusive lock to coordinate the initial fetch, then allow shared reads later. Here's a simple single-flight loader:


let cachedData = null;

async function loadCriticalData() {
  // Try shared lock for reading cached data
  const result = await navigator.locks.request('data-loader', { mode: 'shared', ifAvailable: true }, async () => {
    if (cachedData) {
      console.log('Using cached data');
      return cachedData;
    }
    // If cache empty, release shared and retry exclusive
    return undefined;
  });

  if (result) return result;

  // Fallback: acquire exclusive lock to fetch and cache
  return await navigator.locks.request('data-loader', { mode: 'exclusive' }, async () => {
    // Double-check in case another tab filled the cache while we waited
    if (cachedData) return cachedData;
    console.log('Fetching data...');
    const response = await fetch('/api/large-data');
    cachedData = await response.json();
    return cachedData;
  });
}

Example 4: Using Locks with Service Workers

Service workers can also use the Web Locks API (they share the same origin). This is useful for coordinating cache updates. The following service worker code ensures only one instance performs a cache purge at a time.


self.addEventListener('message', async (event) => {
  if (event.data.type === 'PURGE_CACHE') {
    try {
      await navigator.locks.request('cache-purge', { mode: 'exclusive', signal: AbortSignal.timeout(10000) }, async () => {
        const cacheNames = await caches.keys();
        for (const name of cacheNames) {
          await caches.delete(name);
        }
        console.log('All caches purged');
        event.ports[0].postMessage({ success: true });
      });
    } catch (err) {
      event.ports[0].postMessage({ success: false, error: err.message });
    }
  }
});

Best Practices

Conclusion

The Web Locks API fills a crucial gap in the web platform by offering a native, promise-based locking mechanism for coordinating concurrent operations within the same origin. It empowers developers to build more robust web applications that safely manage shared state across multiple tabs, workers, and service workers. By embracing best practices—short lock scopes, abort signals, and appropriate lock modes—you can eliminate data races, reduce redundant work, and deliver a seamless, consistent experience to users even in complex multi-context environments. As browser support continues to expand, integrating the Web Locks API into your modern web application toolkit is a wise investment in reliability and maintainability.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles