npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

isotropic-mutex

v0.5.0

Published

In-process resource locking for JavaScript

Readme

isotropic-mutex

npm version License

A utility for in-process resource locking in JavaScript, providing advanced concurrency control for asynchronous code.

Why Use This?

  • Controlled Concurrency: Prevent race conditions in complex asynchronous applications
  • Multiple Lock Types: Support for exclusive, shared, and upgradable locks
  • Synchronous Attempts: Non-blocking try methods that acquire a lock immediately or return null
  • Automatic Cleanup: First-class support for the using declaration so locks are always released, even when errors are thrown
  • Promise-Based API: Seamless integration with async/await
  • Cancelable Operations: Cancel pending lock requests manually or with an AbortSignal
  • Timeout Support: Specify timeouts for lock acquisition
  • Structured Errors: Rejections carry a name (TimeoutError, AbortError, CanceledError, DisposedError, or UpgradeError) and a details object
  • Resource Flexibility: Lock any value (string, object, symbol, etc.)

Installation

npm install isotropic-mutex

Usage

The preferred way to use a lock is with the using declaration. Each acquired lock is a disposable resource, so it is released automatically when execution leaves the block in which it was declared, whether the block completes normally, returns, or throws. This makes deadlocks from a forgotten unlock() impossible.

import _Mutex from 'isotropic-mutex';

// Basic exclusive lock
const _accessResource = async () => {
        using lock = await _Mutex.exclusive({
            resource: 'my-resource'
        });

        // Perform operations on the resource.
        // No other exclusive or shared locks can be acquired during this time.
        console.log(`Accessing resource with ${lock.mode} access`);

        await someAsyncOperation();

        // The lock is released automatically when this function returns.
    },
    // Shared lock (multiple readers)
    _readResource = async () => {
        using lock = await _Mutex.shared({
            resource: 'my-resource'
        });

        // Multiple shared locks can exist simultaneously,
        // but exclusive locks will be blocked.
        console.log(`Reading resource with ${lock.mode} access`);

        await readSomeData();
    },

    // Upgradable lock (reader that might need to write)
    _accessResourceWithPossibleUpdate = async () => {
        using lock = await _Mutex.upgradable({
            resource: 'my-resource'
        });

        // Start with read access.
        console.log(`Reading resource with ${lock.mode} access`);

        if (needToModify()) {
            // Upgrade to exclusive access when needed.
            // The upgraded lock is also disposable.
            {
                using upgradedLock = await lock.upgrade();

                console.log(`Now have ${upgradedLock.mode} access`);
                await modifyResource();

                // Upgrading the lock in a nested block means it will
                // downgrade as soon as the block is exited.
            }

            // Back to upgradable access here; still holding the outer lock.
            await readSomeMoreData();
        }
    };

Scoping with using

A using declaration ties the lifetime of a lock to its enclosing block. If you want to hold a lock for an entire function, declare it at the top of the function body. If you only need it for part of the work, wrap that part in a block:

import _Mutex from 'isotropic-mutex';

const _processData = async () => {
    await prepareWork();

    {
        // Critical section: the lock is held only within this block.
        using lock = await _Mutex.exclusive({
            resource: 'database'
        });

        await updateDatabase();
    }

    // The lock has been released!
    // Continue with non-critical work.
    await cleanUp();
};

When multiple locks are declared in the same scope, they are released in reverse order of acquisition (last-in-first-out), which is the correct order for safely releasing nested locks.

Manual unlocking

If you are running on a JavaScript runtime that does not yet support using, or you need to release a lock outside of the lexical block that acquired it, you can manage the lock manually with unlock(). Always release the lock in a finally block so that it is released even if an error is thrown.

import _Mutex from 'isotropic-mutex';

const _accessResource = async () => {
    const lock = await _Mutex.exclusive({
        resource: 'my-resource'
    });

    try {
        console.log('Accessing resource exclusively');
        await someAsyncOperation();
    } finally {
        // Always release the lock when done.
        lock.unlock();
    }
};

Calling unlock() and disposal are both idempotent, so it is safe to mix the two approaches; releasing a lock more than once has no effect.

API

Mutex.exclusive({ resource, signal, timeout })

Acquires an exclusive lock on a resource. Exclusive locks prevent any other locks from being held at the same time.

Parameters

  • resource (Any): The resource to lock (can be a string, object, symbol, etc.)
  • signal (AbortSignal, optional): An AbortSignal that cancels the pending request with an AbortError if it is aborted before the lock is granted. If the signal is already aborted, the request rejects immediately.
  • timeout (Number | Temporal.Duration, optional): Time to wait before the request is automatically canceled with a TimeoutError. A number is interpreted as milliseconds. Note that 0 or negative timeouts follow the same 'soon' or 'asap' functionality as isotropic-later. Omitting the timeout or passing an invalid value means the request waits indefinitely. For a synchronous attempt that never queues, use tryExclusive instead.

Returns

  • Promise that resolves to an object with:
    • mode (String): Always "exclusive"
    • unlock (Function): Function to release the lock
    • [Symbol.dispose] (Function): Releases the lock; called automatically by a using declaration
  • The promise also has a cancel method (see Promise.cancel) and a mode property.

Mutex.shared({ resource, signal, timeout })

Acquires a shared lock on a resource. Multiple shared locks can be held simultaneously, but not with exclusive locks.

Parameters

  • resource (Any): The resource to lock
  • signal (AbortSignal, optional): An AbortSignal that cancels the pending request with an AbortError if it is aborted before the lock is granted. If the signal is already aborted, the request rejects immediately.
  • timeout (Number | Temporal.Duration, optional): Time to wait before the request is automatically canceled with a TimeoutError. A number is interpreted as milliseconds. Note that 0 or negative timeouts follow the same 'soon' or 'asap' functionality as isotropic-later. Omitting the timeout or passing an invalid value means the request waits indefinitely. For a synchronous attempt that never queues, use tryExclusive instead.

Returns

  • Promise that resolves to an object with:
    • mode (String): Always "shared"
    • unlock (Function): Function to release the lock
    • [Symbol.dispose] (Function): Releases the lock; called automatically by a using declaration
  • The promise also has a cancel method (see Promise.cancel) and a mode property.

Mutex.upgradable({ resource, signal, timeout })

Acquires an upgradable lock on a resource. Upgradable locks can coexist with shared locks and can be upgraded to exclusive.

Parameters

  • resource (Any): The resource to lock
  • signal (AbortSignal, optional): An AbortSignal that cancels the pending request with an AbortError if it is aborted before the lock is granted. If the signal is already aborted, the request rejects immediately.
  • timeout (Number | Temporal.Duration, optional): Time to wait before the request is automatically canceled with a TimeoutError. A number is interpreted as milliseconds. Note that 0 or negative timeouts follow the same 'soon' or 'asap' functionality as isotropic-later. Omitting the timeout or passing an invalid value means the request waits indefinitely. For a synchronous attempt that never queues, use tryExclusive instead.

Returns

  • Promise that resolves to an object with:
    • mode (String): Always "upgradable"
    • tryUpgrade (Function): Synchronously upgrades to an exclusive lock, or returns null (see tryUpgrade)
    • unlock (Function): Function to release the lock
    • upgrade (Function): Function to upgrade to an exclusive lock (see upgrade)
    • [Symbol.dispose] (Function): Releases the lock; called automatically by a using declaration
  • The promise also has a cancel method (see Promise.cancel) and a mode property.

upgrade({ signal, timeout })

Upgrades an upgradable lock to an exclusive lock. Resolves once any outstanding shared locks have been released.

Parameters

  • signal (AbortSignal, optional): An AbortSignal that cancels the pending upgrade with an AbortError if it is aborted before the upgrade is granted. If the signal is already aborted, the upgrade rejects immediately.
  • timeout (Number | Temporal.Duration, optional): Time to wait before the request is automatically canceled with a TimeoutError. A number is interpreted as milliseconds. Note that 0 or negative timeouts follow the same 'soon' or 'asap' functionality as isotropic-later. Omitting the timeout or passing an invalid value means the request waits indefinitely. For a synchronous attempt that never queues, use tryExclusive instead.

Returns

  • Promise that resolves to an object with:
    • mode (String): Always "upgraded"
    • downgrade (Function): Function to downgrade back to an upgradable lock
    • [Symbol.dispose] (Function): Downgrades back to an upgradable lock; called automatically by a using declaration
  • Rejects with an UpgradeError if the upgradable lock has already been released, the lock is already upgraded, or an upgrade has already been requested.

Mutex.tryExclusive({ resource })

Synchronously attempts to acquire an exclusive lock without queuing. Returns the lock object immediately if it can be granted right now, otherwise returns null. This never waits, so it accepts neither a signal nor a timeout.

Parameters

  • resource (Any): The resource to lock

Returns

  • The same lock object as Mutex.exclusive (with mode, unlock, and [Symbol.dispose]) if granted, or null if any other lock is currently held.

Mutex.tryShared({ resource })

Synchronously attempts to acquire a shared lock without queuing.

Parameters

  • resource (Any): The resource to lock

Returns

  • The same lock object as Mutex.shared if granted, or null if an exclusive lock is held, an exclusive request is already queued (writer preference), or an upgrade is pending.

Mutex.tryUpgradable({ resource })

Synchronously attempts to acquire an upgradable lock without queuing.

Parameters

  • resource (Any): The resource to lock

Returns

  • The same lock object as Mutex.upgradable if granted, or null if an exclusive lock is held, an exclusive request is queued, or another upgradable lock is held.

tryUpgrade()

Synchronously attempts to upgrade an upgradable lock to exclusive without queuing.

Returns

  • The same upgraded lock object as upgrade (with mode, downgrade, and [Symbol.dispose]) if it can be granted immediately, or null if any shared locks are still held, the upgradable lock has already been released, the lock is already upgraded, or an asynchronous upgrade is already pending.

Promise.cancel({ reason, signal })

Every lock acquisition promise (and the promise returned by upgrade) has a cancel method that cancels a pending request. Canceling a request that has already been granted, already settled, or already canceled has no effect.

  • cancel() - Called with no argument, cancels the pending request immediately, rejecting it with a CanceledError.
  • cancel({ reason }) - Called with an Error, cancels the pending request immediately, rejecting it with the given error.
  • cancel({ signal }) - Called with an AbortSignal, arms the cancellation so it fires when the signal aborts (or immediately, if the signal is already aborted), rejecting the request with an AbortError.

There are therefore two ways to wire up an AbortSignal: pass it as the signal option when requesting the lock, or pass it to cancel. Either or both may be used on the same request; whichever trigger fires first wins.

import _Mutex from 'isotropic-mutex';

const _attempt = async signal => {
    const lockPromise = _Mutex.exclusive({
        resource: 'resource'
    });

    // Equivalent to passing { resource, signal } to exclusive.
    lockPromise.cancel({
        signal
    });

    using lock = await lockPromise;

    await doWork();
};

mutex.cancelAll({ reason, signal })

Cancels every pending lock request and pending upgrade on the mutex instance. Granted locks are unaffected, and the mutex remains fully usable afterwards. Requests are rejected the same way Promise.cancel rejects them:

  • cancelAll() - Rejects every pending request with a CanceledError.
  • cancelAll({ reason }) - Rejects every pending request with the given error.
  • cancelAll({ signal }) - Called with an AbortSignal that has not yet aborted, arms the cancellation so it fires when the signal aborts, rejecting every request that is pending at that time with an AbortError. A signal that has already aborted cancels immediately.

Returns the mutex instance for chaining. The default static mutex exposes Mutex.cancelAll as well, which cancels the pending requests of the shared static instance.

import _Error from 'isotropic-error';
import _Mutex from 'isotropic-mutex';

const _mutex = _Mutex();

// ... issue lock requests ...

// On shutdown, reject everything still waiting.
_mutex.cancelAll({
    reason: _Error({
        message: 'Shutting down',
        name: 'ShutdownError'
    })
});

Errors

When a request is rejected, the rejection is an isotropic-error (an Error instance) with a name and a details object. The details always include the mode ("exclusive", "shared", "upgradable", or "upgraded") and the resource.

| name | When | Extra details | cause | |------------------|----------------------------------------------------------------------|-----------------|------------------| | AbortError | A signal (via the option or cancel) aborted before the grant | - | the abort reason | | CanceledError | cancel() was called with no reason or signal | - | - | | DisposedError | The mutex was disposed while the request was pending | - | - | | TimeoutError | The timeout elapsed before the lock was granted | duration | - | | UpgradeError | An upgrade/tryUpgrade was invalid, or a pending upgrade's upgradable lock was unlocked | - | - | | custom reason | cancel({ reason }) or cancelAll({ reason }) rejects with the given reason | - | - |

import _Mutex from 'isotropic-mutex';

const _attempt = async () => {
    try {
        using lock = await _Mutex.exclusive({
            resource: 'resource',
            timeout: 5000
        });

        await doWork();
    } catch (error) {
        if (error.name === 'TimeoutError') {
            console.error(`Timed out after ${error.details.duration}ms`);
        } else {
            throw error;
        }
    }
};

Symbol.dispose

Every resolved lock object (exclusive, shared, upgradable, and upgraded) implements the [Symbol.dispose]() method from the explicit resource management proposal. Disposing a lock releases it: for exclusive, shared, and upgradable locks this is equivalent to calling unlock(), and for an upgraded lock it is equivalent to calling downgrade().

This is what makes the using declaration work:

{
    using lock = await _Mutex.exclusive({
        resource: 'my-resource'
    });

    // ... use the lock ...
} // lock[Symbol.dispose]() runs here, releasing the lock

Because releasing a lock is synchronous, Symbol.dispose (rather than Symbol.asyncDispose) is the correct interface. Note that the lock is acquired asynchronously but released synchronously, so the natural form is using lock = await _Mutex.exclusive(...) - await applies to the acquisition and using manages the disposal. An await using declaration also works (it falls back to Symbol.dispose), but the plain using declaration is preferred since there is no asynchronous cleanup to await.

Mutex instances also implement [Symbol.dispose](). Disposing a mutex is equivalent to calling cancelAll with a DisposedError reason: every pending lock request and pending upgrade rejects, while granted locks remain granted.

{
    using mutex = _Mutex();

    // ... issue lock requests ...
} // Pending requests reject with a DisposedError here.

Lock Compatibility Chart

| Current Lock | Exclusive Request | Shared Request | Upgradable Request | Upgrade Request | |--------------|------------------|----------------|-------------------|----------------| | None | ✅ Granted | ✅ Granted | ✅ Granted | N/A | | Exclusive | ⏱️ Queued | ⏱️ Queued | ⏱️ Queued | N/A | | Shared | ⏱️ Queued | ✅ Granted | ✅ Granted | N/A | | Upgradable | ⏱️ Queued | ✅ Granted | ⏱️ Queued | ✅ if no shared locks⏱️ Queued otherwise | | Upgraded | ⏱️ Queued | ⏱️ Queued | ⏱️ Queued | ❌ Already upgraded |

The try methods (tryExclusive, tryShared, tryUpgradable, and tryUpgrade) follow the same rules but never queue: wherever the chart shows ⏱️ Queued, the corresponding try method returns null instead.

Examples

Basic Lock Acquisition

import _Mutex from 'isotropic-mutex';

const _processData = async () => {
    // Acquire an exclusive lock that is released automatically.
    using lock = await _Mutex.exclusive({
        resource: 'database'
    });

    // Critical section: only one execution at a time.
    await updateDatabase();
};

Automatic Release on Error

Because a using lock is disposed as execution leaves its block, the lock is released even when the protected code throws. This is the simplest way to avoid deadlocks caused by an unreleased lock.

import _Mutex from 'isotropic-mutex';

const _processData = async () => {
    using lock = await _Mutex.exclusive({
        resource: 'database'
    });

    // If this throws, the lock is still released as the error propagates.
    await updateDatabase();

    // No try/finally needed to guarantee the lock is released.
};

Lock with Timeout

import _Mutex from 'isotropic-mutex';

const _processWithTimeout = async () => {
    try {
        // Will reject if the lock can't be acquired within 5 seconds.
        using lock = await _Mutex.exclusive({
            resource: 'api-endpoint',
            timeout: 5000
        });

        await callApi();
    } catch (error) {
        console.error('Failed to acquire lock within timeout', error);
        // Handle timeout case
    }
};

Canceling a Lock Request

import _later from 'isotropic-later';
import _Mutex from 'isotropic-mutex';

const _attemptProcessing = async () => {
    const lockPromise = _Mutex.exclusive({
            resource: 'resource'
        }),
        // Cancel the pending request if it takes too long.
        timer = _later(3000, () => {
            console.log('Canceling lock request');
            lockPromise.cancel();
        });

    try {
        // If the request was canceled, awaiting the promise throws here.
        using lock = await lockPromise;

        // If we get here, we acquired the lock before cancellation.
        timer.cancel();

        await doWork();
    } catch (error) {
        console.log('Lock was canceled or failed', error);
    }
};

Synchronous (Non-Blocking) Attempts

The try methods acquire a lock immediately or give up right away, which is useful when you would rather do something else than wait. They return a lock object on success or null on failure, and the returned lock works with using just like the asynchronous variants.

import _Mutex from 'isotropic-mutex';

const _processIfFree = () => {
    using lock = _Mutex.tryExclusive({
        resource: 'resource'
    });

    if (!lock) {
        // Someone else holds the resource; skip this round instead of waiting.
        return false;
    }

    doWorkSynchronously();

    return true;
};

tryUpgrade is the synchronous counterpart to upgrade. It succeeds only when no shared locks are still held:

import _Mutex from 'isotropic-mutex';

const _updateIfPossible = () => {
    using lock = _Mutex.tryUpgradable({
        resource: 'config'
    });

    if (lock) {
        using upgradedLock = lock.tryUpgrade();

        if (upgradedLock) {
            // No readers were in the way, so we hold exclusive access now.
            updateConfigSynchronously();
        }
    }
};

Canceling with an AbortSignal

In addition to cancel(), a pending request can be canceled by an AbortSignal. Pass the signal as the signal option (or to cancel), and the request rejects with an AbortError when the signal aborts.

import _Mutex from 'isotropic-mutex';

const _attemptProcessing = async signal => {
    try {
        // Rejects with an AbortError if `signal` aborts before the lock is granted.
        using lock = await _Mutex.exclusive({
            resource: 'resource',
            signal
        });

        await doWork();
    } catch (error) {
        if (error.name === 'AbortError') {
            console.log('Lock request was aborted', error.cause);
        } else {
            throw error;
        }
    }
};

Reader-Writer Pattern

import _Mutex from 'isotropic-mutex';

// Multiple readers can access simultaneously
const _readData = async () => {
        using lock = await _Mutex.shared({
            resource: 'data'
        });

        // Multiple readers can execute this simultaneously.
        return await fetchData();
    },
    // Writers need exclusive access
    _writeData = async newData => {
        using lock = await _Mutex.exclusive({
            resource: 'data'
        });

        // Only one writer, and no readers, can execute this at a time.
        await saveData(newData);
    };

Upgradable Lock Example

import _Mutex from 'isotropic-mutex';

const _checkAndUpdateIfNeeded = async () => {
    // Start with an upgradable lock (compatible with shared locks).
    using lock = await _Mutex.upgradable({
        resource: 'config'
    });

    // Read the data (other readers can do this too).
    const config = await readConfig();

    if (needsUpdate(config)) {
        // Upgrade to exclusive access to make changes. Scoping the upgraded
        // lock in a nested block downgrades back to upgradable access as soon
        // as the block is exited.
        {
            using upgradedLock = await lock.upgrade();

            // Now we have exclusive access.
            await updateConfig(config);
        }

        // Back to upgradable access; do more read-only operations if needed.
    }

    // The upgradable lock is released automatically here.
};

Working with Multiple Resources

import _Mutex from 'isotropic-mutex';

const _transferBetweenAccounts = async ({
    amount,
    fromId,
    toId
}) => {
    // Always lock in a consistent order to prevent deadlocks.
    const sortedIds = [
        fromId,
        toId
    ].sort();

    // Both locks are declared together, so they are released in last-in-first-out
    // order when this function returns.
    using fromLock = await _Mutex.exclusive({
            resource: sortedIds[0]
        }),
        toLock = await _Mutex.exclusive({
            resource: sortedIds[1]
        });

    // Now we have exclusive access to both accounts.
    await withdraw(fromId, amount);
    await deposit(toId, amount);
};

Creating Multiple Mutex Instances

While you can use the default instance (Mutex), you can also create separate instances that operate independently:

import _Mutex from 'isotropic-mutex';

// Create separate mutex instances
const _databaseMutex = _Mutex(),
    _fileMutex = _Mutex(),
    // Use them independently
    _processData = async () => {
        using lock = await _databaseMutex.exclusive({
            resource: 'users'
        });

        // Database operations
    },
    _writeFile = async () => {
        using lock = await _fileMutex.exclusive({
            resource: 'log.txt'
        });

        // File operations
    };

Prioritization

When multiple lock requests are queued:

  1. Exclusive locks have highest priority
  2. Shared locks have medium priority
  3. Upgradable locks have lowest priority

This prevents upgradable locks from continuously blocking exclusive requests, which could lead to starvation.

Use Cases

  • Data Synchronization: Coordinating access to shared data structures
  • Resource Management: Preventing parallel access to limited resources
  • Sequential Processing: Ensuring operations happen in a specific order
  • Critical Sections: Protecting sections of code from concurrent execution
  • Read/Write Control: Implementing reader-writer patterns

Contributing

Please refer to CONTRIBUTING.md for contribution guidelines.

Issues

If you encounter any issues, please file them at https://github.com/ibi-group/isotropic-mutex/issues