Skip to content

@studnicky/mutex

Key-based async mutex for preventing race conditions in concurrent operations.

Install

bash
pnpm add @studnicky/mutex

Usage

Acquire a lock on a named key with runExclusive. Different keys run concurrently; the same key serializes:

ts
import { Mutex } from '../src/index.js';

const mutex = Mutex.create<string>();

// Different keys run concurrently — track completion order
const completionOrder: string[] = [];

class KeyedMutexDemo {
  static async runParallelKeys(): Promise<void> {
    // Two concurrent runExclusive calls on DIFFERENT keys should both start immediately
    await Promise.all([
      mutex.runExclusive('keyA', () => {
        completionOrder.push('keyA');
      }),
      mutex.runExclusive('keyB', () => {
        completionOrder.push('keyB');
      })
    ]);

    console.log('Completion order (parallel keys):', completionOrder);
  }

  static async runSerialSameKey(): Promise<void> {
    // Concurrent calls on the SAME key must serialize
    let counter = 0;
    const results: number[] = [];

    await Promise.all([
      mutex.runExclusive('shared', () => {
        const snapshot = counter;
        counter++;
        results.push(snapshot);
      }),
      mutex.runExclusive('shared', () => {
        const snapshot = counter;
        counter++;
        results.push(snapshot);
      }),
      mutex.runExclusive('shared', () => {
        const snapshot = counter;
        counter++;
        results.push(snapshot);
      })
    ]);

    console.log('Serialized counter:', counter);
    console.log('Serialized results:', results.toSorted((a, b) => { const result = a - b; return result; }));
  }

  static showStats(): void {
    const stats = mutex.getStats();
    console.log('Stats:', stats);
  }
}

await KeyedMutexDemo.runParallelKeys();
await KeyedMutexDemo.runSerialSameKey();
KeyedMutexDemo.showStats();

Manual acquire/release

Use acquire() when you need explicit try/finally control, or acquireDisposable() for a releaseable handle:

ts
import { Mutex } from '../src/index.js';

const mutex = Mutex.create<string>();

class AcquireReleaseDemo {
  static async runManualRelease(): Promise<void> {
    const release = await mutex.acquire('resource');

    // Queue a second waiter without awaiting — it will sit in the queue
    let secondAcquired = false;
    const secondPromise = mutex.acquire('resource').then((rel) => {
      secondAcquired = true;
      rel();
    });

    // The second waiter is in the queue
    console.log('Queue size while locked:', mutex.queueSize('resource'));

    try {
      // Critical section — 'resource' is exclusively held here
      console.log('Is locked:', mutex.isLocked('resource'));
    } finally {
      release();
    }

    // Wait for the queued waiter to complete
    await secondPromise;

    console.log('Second acquired:', secondAcquired);
    console.log('Is locked after all released:', mutex.isLocked('resource'));
    console.log('Queue size after completion:', mutex.queueSize('resource'));
  }

  static async runDisposable(): Promise<void> {
    // acquireDisposable — manual release via .release()
    const lock = await mutex.acquireDisposable('disposable');

    console.log('Lock key:', lock.key);
    console.log('Is locked (disposable):', mutex.isLocked('disposable'));

    lock.release();

    console.log('Is locked after release:', mutex.isLocked('disposable'));
  }
}

await AcquireReleaseDemo.runManualRelease();
await AcquireReleaseDemo.runDisposable();

Try it

The hooks demo subclasses Mutex and overrides eight protected lifecycle methods. Observe the trace: beforeAcquire fires for every caller regardless of contention; onContended fires only for the queued waiter; onAcquireWait fires only after the waiter acquires through the queue; and onQueueDrain fires once the key's queue empties.

Loading example…

Public API

Import Mutex, MutexCreateOptionsInterface, its schema-backed entities, package errors, MutexInterface, and MutexLockInterface from @studnicky/mutex. Create instances directly with Mutex.create(config?); implementation constants remain internal.

Pass a ClockProviderInterface through clock when application timing needs to be deterministic. Mutex uses that provider for acquisition waits and lock-hold measurements; the default is RealTimeClockProvider.

Observability hooks

Subclass Mutex and override any protected hook to inject trace logging, metrics, or side-effects at the exact stage where they are needed. Hooks should stay fast and non-blocking; observer-hook failures are contained by the base class so lock acquisition and release semantics still win.

HookWhen it firesArgs
beforeAcquire(key)Before any acquisition attempt (immediate or queued)key: K
afterAcquire(key, waitTimeMs)After lock is granted (both immediate and queued paths)key: K, waitTimeMs: number
onAcquireWait(key, waitTimeMs)After a queued waiter finally acquires the lock (never fires for immediate grants)key: K, waitTimeMs: number
onContended(key, queueSize)When a caller finds the lock held and enqueues itselfkey: K, queueSize: number (depth before enqueue)
onRelease(key)On every lock release by its holder, before any handoff or dropkey: K
beforeRelease(key, holdTimeMs)Before a lock is released, with hold timekey: K, holdTimeMs: number
afterRelease(key)After the lock is dropped completely (no waiters remained)key: K
onQueueDrain(key)When the last waiter for a key leaves the queue (by acquiring or timing out)key: K
onTimeout(key, timeoutMs)When a queued acquisition exceeds the configured timeoutkey: K, timeoutMs: number
onEnterKey(key, to, from)On every per-key FSM state transitionkey: K, to: MutexKeyStateEntity.Type, from: MutexKeyStateEntity.Type
ts
import type { AcquireWaitEventEntity, QueueDrainEventEntity, ReleaseEventEntity } from '../src/entities/index.js';

import { Mutex } from '../src/index.js';

class TracingMutex extends Mutex<string> {
  readonly acquireWaitEvents: AcquireWaitEventEntity.Type[] = [];
  readonly queueDrainEvents: QueueDrainEventEntity.Type[] = [];
  readonly releaseEvents: ReleaseEventEntity.Type[] = [];

  protected override beforeAcquire(key: string): void {
    console.log(`[mutex] beforeAcquire key=${key}`);
  }

  protected override afterAcquire(key: string, waitTimeMs: number): void {
    console.log(`[mutex] afterAcquire key=${key} waitTimeMs=${waitTimeMs}`);
  }

  protected override onAcquireWait(key: string, waitTimeMs: number): void {
    console.log(`[mutex] onAcquireWait key=${key} waitTimeMs=${waitTimeMs}`);
    this.acquireWaitEvents.push({ 'key': key, 'waitTimeMs': waitTimeMs });
  }

  protected override onContended(key: string, queueSize: number): void {
    console.log(`[mutex] onContended key=${key} queueSize=${queueSize}`);
  }

  protected override onRelease(key: string): void {
    console.log(`[mutex] onRelease key=${key}`);
    this.releaseEvents.push({ 'key': key });
  }

  protected override beforeRelease(key: string, holdTimeMs: number): void {
    console.log(`[mutex] beforeRelease key=${key} holdTimeMs=${holdTimeMs}`);
  }

  protected override afterRelease(key: string): void {
    console.log(`[mutex] afterRelease key=${key}`);
  }

  protected override onQueueDrain(key: string): void {
    console.log(`[mutex] onQueueDrain key=${key}`);
    this.queueDrainEvents.push({ 'key': key });
  }

  protected override onTimeout(key: string, timeoutMs: number): void {
    console.log(`[mutex] onTimeout key=${key} timeoutMs=${timeoutMs}`);
  }
}

// Runs the contention scenario end-to-end so the module ends up with a single
// top-level `mutex` binding instead of one per intermediate step.
class MutexDemoRunner {
  static async run(mutex: TracingMutex): Promise<void> {
    // Caller 1 grabs the lock
    const release1 = await mutex.acquire('account-42');

    // Caller 2 enqueues (runs concurrently, not awaited yet)
    const pending2 = mutex.acquire('account-42');

    // Simulate holder doing work
    await new Promise<void>((resolve) => { setTimeout(resolve, 10); });

    // Release — hands lock to caller 2
    release1();

    // Caller 2 now gets the lock
    const release2 = await pending2;

    release2();

    // Caller 3 — different key, immediate acquisition, no contention
    const accountBalances: number[] = [];
    await mutex.runExclusive('account-99', () => {
      accountBalances.push(42);
    });
  }
}

const mutex = TracingMutex.create();
await MutexDemoRunner.run(mutex);

The base class never calls any logger or metrics library. All hooks are no-ops by default.

Source on GitHub

Entities

@studnicky/mutex/entities exports every schema namespace in src/entities.

typescript
import { MutexConfigEntity } from '@studnicky/mutex/entities';

Interfaces

@studnicky/mutex/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.

typescript
import type { MutexInterface } from '@studnicky/mutex/interfaces';

Exports

SymbolPurposeImport path
MutexProvides mutex functionality.@studnicky/mutex
MutexCreateOptionsInterfaceAccepts mutex settings and the optional Clock provider.@studnicky/mutex
LockTimeoutErrorRepresents lock timeout failures.@studnicky/mutex
MutexErrorRepresents mutex failures.@studnicky/mutex
QueueSizeExceededErrorRepresents queue size exceeded failures.@studnicky/mutex