@rickyli79/async-lock-and-run
v1.0.0
Published
Run async functions under a per-key mutual exclusion lock.
Maintainers
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.
📑 Table of Contents
- 📖 Introduction
- ✨ Features
- 📦 Installation
- 🚀 Quick Start
- 🧩 API
- ⚠️ Semantics & Notes
- 🌐 Browser Support
- 🛠️ Development
- License
📖 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
lockerIdrun serially (FIFO arrival order); calls with differentlockerIds run fully in parallel. - 🎯 Independent calls: every call executes its own
bodyand gets its own result — no sharing or cross-talk. - 🛡️ Error isolation: if a
bodyrejects, only that single call is rejected; queued sibling calls still run, and the lock is always released (guaranteed viatry/finally), so nothing downstream gets stuck. - 🆔 Keys compared by identity: the number
1and the string"1"are different locks, andsymbols are unique. - 🔄 Reentrancy detection: on Node.js (≥ 22.3, which supports
process.getBuiltinModule), re-entering the samelockerIdfrom inside its ownbodyis 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-runOr with npm or yarn:
npm install @rickyli79/async-lock-and-run
# or
yarn add @rickyli79/async-lock-and-runThe package is published to the public npm registry (
https://registry.npmjs.org) withpublishConfig.access = public.
Module formats (ESM + CJS)
The package ships dual builds (generated by tsup). The exports map:
| Entry | Type declarations | Notes |
| ---------------------------- | ------------------ | --------- |
| import → dist/index.js | dist/index.d.ts | ESM entry |
| require → dist/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
bodythrows/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
- Per-
lockerIdmutual exclusion (FIFO): calls for the samelockerIdqueue strictly in arrival order, one at a time; calls for differentlockerIds never block each other and run fully in parallel. - Independent calls: every call runs its own
bodyand gets its own result — results are never shared or reused across calls. - Error isolation: a rejected
bodyonly rejects that single call; queued siblings still run, and the lock is always released (guaranteed bytry/finallyinternally). - Keys compared by identity: the number
1and the string"1"are different locks, andsymbols are unique. Make sure callers pass a consistentlockerIdtype, or you'll get locks that "look the same but aren't". - Reentrancy detection (Node.js ≥ 22.3): re-entering
asyncLockAndRunwith the samelockerIdfrom inside its ownbodywould 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 differentlockerId. - 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 samelockerIdfrom inside its ownbodywill 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_hooksequivalent, so reentrancy detection is disabled. - Therefore, in the browser, re-entering the same
lockerIdfrom inside its ownbodydeadlocks directly — avoid that pattern (use a differentlockerIdor 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