How to Use the Web Locks API to Stop Duplicate Browser Jobs

How to Use the Web Locks API to Stop Duplicate Browser Jobs

Use the Web Locks API when the same web app can be open in multiple tabs and only one tab or worker should run a browser-side job at a time. It is a clean fit for sync jobs, cache refreshes, IndexedDB maintenance, and polling loops that should not stampede just because the user opened your app twice.

It is not a database lock. It is not a security feature. It does not coordinate different users, devices, browsers, servers, or origins. It coordinates scripts from the same origin in windows and workers.

The API requires a secure context. It is only available over HTTPS (or localhost). Calls to navigator.locks in an HTTP context will be undefined.

The duplicate-tab problem

This kind of code looks harmless:

setInterval(syncPendingChanges, 30_000);

Then the user opens the dashboard in three tabs.

Now the same origin may try to run the same sync job three times. If the job touches IndexedDB and your backend, you can get duplicate requests, noisy logs, racey updates, and wasted battery.

You can invent a localStorage lock, but then you own stale lock cleanup, clock drift, tab crashes, and edge cases. Web Locks gives the browser a native coordination primitive for this specific problem.

Request a lock around the work

The main entry point is navigator.locks.request(). Give the lock a stable name and run the protected work inside the callback:

await navigator.locks.request("sync-pending-changes", async () => {
  await syncPendingChanges();
});

While that callback is running, another tab or worker from the same origin that requests the same lock waits its turn. The lock is released when the callback finishes or throws.

For app code, wrap that into a small helper:

export async function withBrowserLock(name, task) {
  if (!("locks" in navigator)) {
    return task();
  }

  return navigator.locks.request(name, async () => task());
}

Then use it where duplicate work can happen:

await withBrowserLock("sync-pending-changes", async () => {
  await syncPendingChanges();
});

This is the same kind of small JavaScript helper discipline that makes tutorials like coding a card deck in JavaScript useful: keep the repeated pattern obvious, named, and easy to call from the rest of the app.

Pick lock names like resource names

The lock name is chosen by your app. Treat it like the shared resource being protected.

Good names:

"sync-pending-changes"
"refresh-account-cache"
"indexeddb-maintenance"
"send-queued-analytics"

Bad names:

userTypedSearchQuery
Date.now().toString()
crypto.randomUUID()

A lock name must be stable across the contexts that are competing for the same work. If every tab generates a different name, nothing is coordinated.

One hard constraint: a name that starts with a hyphen (-) will throw a NotSupportedError. Stick to descriptive strings that read like resource identifiers.

If a name comes from dynamic app code, validate it before requesting the lock. The same basic care behind checking for an empty string in JavaScript applies here: a missing or accidental resource name can quietly turn your lock into a convention nobody is following.

Skip optional work with ifAvailable

Sometimes tab B should not wait for tab A. It should simply skip the job because another context already owns it.

Use ifAvailable: true:

await navigator.locks.request(
  "refresh-account-cache",
  { ifAvailable: true },
  async (lock) => {
    if (!lock) {
      return;
    }

    await refreshAccountCache();
  },
);

That is a good fit for cache refreshes, optional polling, and background work that can try again later. If the lock is not available immediately, the callback receives null.

Do not use this for work that must eventually happen unless something else reschedules it. Otherwise, "busy right now" becomes "never ran."

Add a timeout with AbortController

If the job matters but should not wait forever, pass an AbortSignal.

async function runSyncWithLockTimeout() {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 2_000);

  try {
    await navigator.locks.request(
      "sync-pending-changes",
      { signal: controller.signal },
      async () => {
        await syncPendingChanges();
      },
    );
  } catch (error) {
    if (error.name === "AbortError") {
      console.warn("Sync lock was not granted before the timeout.");
      return;
    }

    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

The signal can abort the request before the lock is granted. Once the callback is running, aborting the controller has no effect on the callback itself — design your own task cancellation if the work itself needs to stop early.

Note that combining signal with either steal or ifAvailable in the same options object will throw a NotSupportedError. Use one or the other depending on what behavior you need.

Use "shared" mode only when it buys you something

The default mode is "exclusive", which is right for most duplicate jobs.

There is also "shared" mode. Multiple shared holders can hold the same lock at once, but an exclusive request for that name waits until the shared holders are done.

async function readCacheSnapshot() {
  return navigator.locks.request(
    "account-cache",
    { mode: "shared" },
    async () => readFromIndexedDB(),
  );
}

async function rebuildCache() {
  return navigator.locks.request(
    "account-cache",
    { mode: "exclusive" },
    async () => rebuildIndexedDBCache(),
  );
}

This is useful for a readers-writer shape: many readers can run together, but one writer needs the resource alone.

Keep it boring. If your browser lock plan starts to look like a transaction system, the design probably belongs in the database, backend queue, or storage layer instead.

Inspect locks while debugging

When a lock appears stuck, inspect the current snapshot:

const state = await navigator.locks.query();

console.table(
  state.held.map((lock) => ({
    status: "held",
    name: lock.name,
    mode: lock.mode,
    clientId: lock.clientId,
  })),
);

console.table(
  state.pending.map((lock) => ({
    status: "pending",
    name: lock.name,
    mode: lock.mode,
    clientId: lock.clientId,
  })),
);

navigator.locks.query() returns a snapshot with held and pending arrays. Each lock record can include name, mode, and clientId.

Use this for diagnostics. I would not build core product behavior around polling lock snapshots.

For browser behavior testing in general, it helps to have a repeatable setup. The same mindset behind changing browser location and timezone for testing applies here: open multiple controlled tabs, trigger the same action, and inspect browser state instead of guessing.

Be careful with steal

The API has a steal: true option:

await navigator.locks.request(
  "sync-pending-changes",
  { steal: true },
  async () => {
    await syncPendingChanges();
  },
);

Use it rarely. When steal is granted, any code that previously held the lock may continue running alongside the new holder — creating exactly the clash you were trying to avoid. It also cancels all pending queued requests for that lock name, not just the current holder.

Passing steal: true together with ifAvailable: true will throw a NotSupportedError, so the two options cannot be combined.

Most apps should prefer timeouts, idempotent jobs, and simpler task design before reaching for steal.

Where Web Locks fit

Use Web Locks when:

  • the same origin can be open in multiple tabs or workers
  • a browser-side job should run once at a time
  • the job is local to the browser app
  • duplicate work is wasteful or risky, but the server still stays correct

Do not use Web Locks when:

  • you need to coordinate across users, devices, browsers, or servers
  • the backend must enforce uniqueness
  • the work must continue after every tab closes
  • the operation is security-sensitive

For backend writes, keep using server-side idempotency, database constraints, queues, transactions, or whatever the real system needs. Web Locks can reduce duplicate browser work, but the server still needs to be correct.

A practical helper to start with

Here is the version I would start with in a real app:

export async function runOncePerOrigin(name, task, options = {}) {
  const {
    timeoutMs = 2_000,
    skipIfBusy = false,
  } = options;

  if (!("locks" in navigator)) {
    return task();
  }

  if (skipIfBusy) {
    return navigator.locks.request(name, { ifAvailable: true }, async (lock) => {
      if (!lock) {
        return undefined;
      }

      return task();
    });
  }

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await navigator.locks.request(
      name,
      { signal: controller.signal },
      async () => task(),
    );
  } finally {
    clearTimeout(timeoutId);
  }
}

Use it like this:

await runOncePerOrigin("sync-pending-changes", syncPendingChanges);

await runOncePerOrigin("refresh-account-cache", refreshAccountCache, {
  skipIfBusy: true,
});

That gives you a practical default: coordinate same-origin browser jobs, skip optional work when another tab is already doing it, and avoid waiting forever when something goes sideways.

Sources

Walt is a software engineer, startup founder and previous mentor for a coding bootcamp. He has been creating software for the past 20+ years.
No comments posted yet
// Add a comment
// Color Theme

Custom accent
Pick any color
for the accent