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

@rickyli79/async-lock-and-run

v1.0.0

Published

Run async functions under a per-key mutual exclusion lock.

Readme

🔒 async-lock-and-run

Run async functions under a per-key mutual exclusion lock.

Run async functions under mutual exclusion per lockerId (key): calls sharing the same lock run serially (FIFO arrival order), while calls using different locks run fully in parallel. Lightweight, zero runtime dependencies, pure JS/TS — works in the browser too.

npm version npm downloads


📑 Table of Contents


📖 Introduction

@rickyli79/async-lock-and-run is a minimal "per-key mutual exclusion" async lock utility. It is built around a single function, asyncLockAndRun: you provide a lock identifier lockerId and an async function body, and it guarantees that all calls for the same lockerId run serially in arrival (FIFO) order, while calls for different lockerIds run fully in parallel.

Typical use cases:

  • Concurrency limiting for the same resource (e.g. many requests hitting the same endpoint/key — only one runs at a time).
  • Mutually exclusive writes (same-key DB writes, cache updates, token refresh, etc.).
  • You just want to "queue" a piece of async code without pulling in a heavyweight task-queue/semaphore library.

It has zero runtime dependencies — the implementation is pure JS/TS (no static node: imports) — so it works on both Node.js and the browser.

✨ Features

  • 🔑 Per-key mutual exclusion: calls with the same lockerId run serially (FIFO arrival order); calls with different lockerIds run fully in parallel.
  • 🎯 Independent calls: every call executes its own body and gets its own result — no sharing or cross-talk.
  • 🛡️ Error isolation: if a body rejects, only that single call is rejected; queued sibling calls still run, and the lock is always released (guaranteed via try/finally), so nothing downstream gets stuck.
  • 🆔 Keys compared by identity: the number 1 and the string "1" are different locks, and symbols are unique.
  • 🔄 Reentrancy detection: on Node.js (≥ 22.3, which supports process.getBuiltinModule), re-entering the same lockerId from inside its own body is detected and throws (prevents a deadlock); on runtimes without support (e.g. the browser) it degrades to a plain per-key mutex.
  • 🧩 Pure JS/TS, zero deps: no static node: imports — browser-friendly.
  • 📦 Dual format: ships both ESM and CJS with full TypeScript types.

📦 Installation

With pnpm (the project's dev environment requires pnpm ^11.18.0):

pnpm add @rickyli79/async-lock-and-run

Or with npm or yarn:

npm install @rickyli79/async-lock-and-run
# or
yarn add @rickyli79/async-lock-and-run

The package is published to the public npm registry (https://registry.npmjs.org) with publishConfig.access = public.

Module formats (ESM + CJS)

The package ships dual builds (generated by tsup). The exports map:

| Entry | Type declarations | Notes | | ---------------------------- | ------------------ | --------- | | importdist/index.js | dist/index.d.ts | ESM entry | | requiredist/index.cjs | dist/index.d.cts | CJS entry |

Package-level config: type: module, main: ./dist/index.cjs, module: ./dist/index.js.

ESM (recommended)

import { asyncLockAndRun } from "@rickyli79/async-lock-and-run";

CJS

const { asyncLockAndRun } = require("@rickyli79/async-lock-and-run");

🚀 Quick Start

Example 1: concurrency limiting / mutual exclusion

Fire many requests at once, but serialize per resource while different resources run in parallel:

import { asyncLockAndRun } from "@rickyli79/async-lock-and-run";

// Simulates an expensive/limited operation on a resource
async function fetchRemote(key: string): Promise<string> {
  await new Promise((resolve) => setTimeout(resolve, 100));
  return `data:${key}`;
}

// Same lockerId serializes; different lockerIds run in parallel
function loadOnce(key: string): Promise<string> {
  return asyncLockAndRun({ lockerId: key, body: () => fetchRemote(key) });
}

// 3 concurrent requests for "hot" + 1 for "cold"
const [a, b, c, d] = await Promise.all([
  loadOnce("hot"),
  loadOnce("hot"), // queued: waits for the previous "hot" to finish
  loadOnce("hot"), // queued: waits for the first two "hot" calls to finish
  loadOnce("cold"), // different key: fully parallel with "hot", no queueing
]);

console.log(a, b, c, d);

Example 2: error isolation

A failing call does not affect queued siblings, and the lock is released:

import { asyncLockAndRun } from "@rickyli79/async-lock-and-run";

const failing = asyncLockAndRun({
  lockerId: "job",
  body: async () => {
    await new Promise((resolve) => setTimeout(resolve, 10));
    throw new Error("boom");
  },
});

const succeeding = asyncLockAndRun({
  lockerId: "job",
  body: async () => {
    await new Promise((resolve) => setTimeout(resolve, 10));
    return "ok";
  },
});

try {
  await failing; // rejects only this single call
} catch (error) {
  console.error("This call failed:", (error as Error).message); // boom
}

console.log(await succeeding); // "ok" — the queued sibling still runs

🧩 API

The package's core (and only) export is the async function asyncLockAndRun.

Signature

async function asyncLockAndRun<T = void>(arg: AsyncLockAndRun<T>): Promise<T>;

Parameters

arg is an object with two fields:

| Parameter | Type | Required | Description | | -------------- | ---------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | arg.lockerId | string \| number \| symbol | yes | Lock identifier. Calls with the same lockerId are mutually exclusive (FIFO); different lockerIds run fully in parallel. Compared by identity: 1 and "1" are different locks. | | arg.body | () => Promise<T> | yes | The async function to run under the lock; its return value is this call's result. |

Return value

Promise<T>:

  • On success, resolves to body's return value (T).
  • If body throws/rejects, it rejects with the same reason — affecting only this call; queued siblings are unaffected and the lock is always released.
  • On Node.js, a reentrant call (see "Semantics & Notes" below) rejects with an Error.

Type definitions

export type AsyncLockAndRun<T> = {
  lockerId: string | number | symbol;
  body: () => Promise<T>;
};

export async function asyncLockAndRun<T = void>(
  arg: AsyncLockAndRun<T>,
): Promise<T>;

⚠️ Semantics & Notes

  1. Per-lockerId mutual exclusion (FIFO): calls for the same lockerId queue strictly in arrival order, one at a time; calls for different lockerIds never block each other and run fully in parallel.
  2. Independent calls: every call runs its own body and gets its own result — results are never shared or reused across calls.
  3. Error isolation: a rejected body only rejects that single call; queued siblings still run, and the lock is always released (guaranteed by try/finally internally).
  4. Keys compared by identity: the number 1 and the string "1" are different locks, and symbols are unique. Make sure callers pass a consistent lockerId type, or you'll get locks that "look the same but aren't".
  5. Reentrancy detection (Node.js ≥ 22.3): re-entering asyncLockAndRun with the same lockerId from inside its own body would deadlock, so Node.js detects it and throws (reentrant call ... would deadlock). If you need to do nested async work under a lock, use a different lockerId.
  6. Runtimes without detection: on runtimes without async_hooks (e.g. the browser), reentrancy detection is disabled and behavior degrades to a plain per-key mutex — re-entering the same lockerId from inside its own body will deadlock. Avoid reentrancy.

🌐 Browser Support

The implementation is pure JS/TS with no static node: dependencies (node:async_hooks is loaded dynamically, on demand, only on runtimes that support process.getBuiltinModule), so it works directly in browsers / bundlers (Vite, Webpack, etc.).

Differences to be aware of:

  • Browsers have no async_hooks equivalent, so reentrancy detection is disabled.
  • Therefore, in the browser, re-entering the same lockerId from inside its own body deadlocks directly — avoid that pattern (use a different lockerId or restructure).

🛠️ Development

Requirements

  • Node.js (≥ 22.3 to exercise the reentrancy-detection path locally)
  • pnpm ^11.18.0 (devEngines.packageManager; prompts a download if not satisfied)

Scripts

| Command | Description | | ---------------------------- | --------------------------------------------------------------------------------------- | | pnpm run typecheck | Type check (tsc --noEmit) | | pnpm test | Run tests (vitest run, currently 9 tests) | | pnpm run build | Build (tsup → ESM + CJS + d.ts / d.cts) | | pnpm run changelog | Generate CHANGELOG.md (auto-changelog, keepachangelog template, starting at v0.1.0) | | pnpm run changelog:preview | Generate preview CHANGELOG-preview.md |

Project structure

async-lock-and-run/
├── src/
│   ├── index.ts          # Core implementation (asyncLockAndRun + reentrancy detection)
│   └── index.test.ts     # vitest tests (9 cases)
├── tsup.config.ts        # Dual-format build config (ESM + CJS + dts)
├── vitest.config.ts      # Test config
├── tsconfig.json         # TypeScript config
├── .github/workflows/    # CI / auto-publish (publish.yml)
└── package.json

📄 License

MIT © Ricky Li