Stop Your Blazor Tabs from Fighting with the Web Locks API


As .NET developers, our instinct when dealing with concurrency is to look at the backend: optimistic locking in Entity Framework, Redis distributed locks, or background queue orchestration.


We rarely think about client-side concurrency until a user opens our application across three separate browser tabs. Suddenly, all three tabs trigger a local cache warmup, attempt to flush an IndexedDB offline queue simultaneously, and trigger duplicate background sync calls.


Before building an ad-hoc coordination protocol over localStorage events or BroadcastChannel, look at what the browser already provides: the Web Locks API (navigator.locks).


How Web Locks Work

The Web Locks API acts as an origin-wide mutex across tabs, windows, and Web Workers. Instead of managing lock acquisition and manual teardown, you pass a callback to navigator.locks.request(). The browser grants the lock, executes your async callback, and releases the lock the moment the returned promise resolves or rejects.


JavaScript

await navigator.locks.request("indexeddb-sync", async (lock) => {

    // Only one tab in this origin executes this at a time

    await syncOfflineQueue();

});

Because locks are arbitrary names scoped to your origin, you define the granularity—whether that is a coarse application-level mutex like "sync-queue" or a fine-grained resource lock like "document-1042".


Bridging Web Locks into Blazor

Because Web Locks rely on execution scope lifetimes, holding a lock indefinitely across asynchronous user actions requires bridging a JavaScript Promise to a manual release trigger.


1. The JavaScript Interop Layer


JavaScript

const activeLocks = new Map();


export async function acquireLock(requestId, name, mode = "exclusive") {

    let releaseCallback;

    const promise = new Promise(resolve => { releaseCallback = resolve; });

    activeLocks.set(requestId, releaseCallback);


    // This promise stays pending until releaseLock is called

    navigator.locks.request(name, { mode }, async () => {

        await promise;

    });

}


export function releaseLock(requestId) {

    const release = activeLocks.get(requestId);

    if (release) {

        release();

        activeLocks.delete(requestId);

    }

}


2. The C# Service Wrapper

Wrap the JS calls in a scoped or singleton service to provide clean integration within your Razor components:


C#

public class WebLocksService

{

    private readonly IJSRuntime _js;


    public WebLocksService(IJSRuntime js) => _js = js;


    public async Task<IDisposable> AcquireAsync(string lockName, string mode = "exclusive")

    {

        var requestId = Guid.NewGuid().ToString();

        await _js.InvokeVoidAsync("locksInterop.acquireLock", requestId, lockName, mode);

        return new LockReleaser(_js, requestId);

    }


    private sealed class LockReleaser : IDisposable

    {

        private readonly IJSRuntime _js;

        private readonly string _id;


        public LockReleaser(IJSRuntime js, string id) => (_js, _id) = (js, id);


        public void Dispose() => _ = _js.InvokeVoidAsync("locksInterop.releaseLock", _id);

    }

}

Advanced Coordination Patterns

Beyond basic exclusive locks, the Web Locks API includes several flags suited for common frontend edge cases:


Non-Blocking Execution (ifAvailable: true): Ideal for leader-election patterns, such as scheduled client-side telemetry flushes. If another tab already holds "cache-refresh", the callback receives null immediately rather than queuing up redundant work.


Shared Locks (mode: "shared"): Implements reader/writer semantics. Multiple tabs can concurrently read an IndexedDB table, but write operations requesting an exclusive lock will wait until all active readers complete.


Timeouts via AbortSignal: Pass an AbortController.signal in the request options. If a tab gets stuck waiting in the lock queue past a specified threshold, the pending request cancels cleanly and throws an AbortError.


State Inspection (navigator.locks.query()): Returns snapshots of both active (held) and waiting (pending) locks across the origin, making it simple to build client-side diagnostic dashboards.


A Note on steal: true: While the API allows stealing a lock to clear stuck operations, stealing the lock does not terminate the asynchronous code still executing in the original tab. Treat it as an absolute recovery fallback, not standard flow control.


Scope and Boundaries

Web Locks coordinate execution contexts only within the same origin and browser profile. They do not replace server-side database concurrency or distributed locks across different physical users and machines. Additionally, because this is a secure-context API, it requires HTTPS in production.


When building rich client-side Blazor WebAssembly or PWA applications, you don't always need to solve multi-tab concurrency in C#. Using a thin JS interop wrapper around native browser primitives often yields a cleaner, more robust solution.


[source code]

Comments

Popular posts from this blog

Customizing PWA Manifest and Icons for a Polished User Experience 🚀

Offline-First Strategy with Blazor PWAs: A Complete Guide 🚀

Yes, Blazor Server can scale!