@hey-amanthakur/coord-box
v1.0.1
Published
Coord-Box — distributed coordination primitives for Node.js: counting semaphores and an idempotency-key registry. Zero runtime dependencies. Pluggable backends (in-memory, Redis) and framework adapters.
Maintainers
Readme
Coord-Box
A production-grade, framework-agnostic distributed semaphore & idempotency-key library for Node.js
Zero runtime dependencies · Pluggable backends · Counting semaphore · TTL leases · Auto-renewal · Idempotency keys · Single-flight dedup · Express · Fastify · Koa · NestJS
Overview
Coord-Box is a lightweight, framework-agnostic coordination toolkit for Node.js. It ships two primitives:
- Distributed semaphore — an "at most N concurrent workers" limit shared across processes and machines.
- Idempotency registry — exactly-once execution for side-effecting operations, keyed by a client-supplied idempotency key.
Both sit on a clean backend interface so you can run them in-process or on real distributed storage (Redis ships out of the box), and both plug into Express, Fastify, Koa, and NestJS with zero wiring.
Highlights
| | |
| --- | --- |
| Zero dependencies | No transitive supply-chain risk; nothing to audit beyond Node itself. |
| Pluggable backends | In-memory for single-process apps and tests, or the Redis adapter for real distributed coordination. Bring your own via the SemaphoreBackend / IdempotencyBackend interfaces. |
| Counting semaphore | Reserve 1..N permits per operation; the backend guarantees the total never exceeds your maxPermits. |
| TTL leases | Every permit is a lease: if a holder crashes, its slots free themselves after ttlMs. No stuck permits. |
| Auto-renewal | Background lease extension so long jobs never lose their reservation mid-flight. |
| Blocking acquire | withPermits/acquire wait (poll) until permits are free or a timeout elapses. tryAcquire never blocks. |
| Exactly-once idempotency | The first caller runs; concurrent and later duplicates wait for or replay the stored outcome. |
| Observability hooks | onAcquire/onRelease/onRenew/onLost for the semaphore; onCreated/onCompleted/onReplayed/onFailed for idempotency — throwing hooks never break the flow. |
| Framework adapters | Drop-in middleware for Express, Fastify, Koa, and decorators + guard/interceptor for NestJS. |
| Dual ESM + CommonJS | Ships both module formats with full TypeScript type definitions. |
When to use it
Semaphore
Coord-Box is for any resource that allows at most N actors at a time:
- Worker pools / queues — limit in-flight jobs to the pool size across every replica.
- External API rate limits — reserve a fixed concurrency budget for a downstream service.
- Batch downloads — cap parallel chunk transfers.
- Resource-shared jobs — bound memory/connection pressure by limiting concurrent heavy jobs.
For mutual exclusion (exactly one actor), use Lock-Box instead.
Idempotency
Coord-Box deduplicates operations that must run exactly once:
- Payment / checkout flows — the same charge request retried (or sent twice) charges once.
- Webhook delivery — duplicate webhook events are handled once, but return the stored response.
- RPC/queue consumers — at-least-once delivery with exactly-once processing.
- Report generation — concurrent triggers of the same report wait for and share the single run.
Table of Contents
- Installation
- Quick start
- Core concepts
- Semaphore API
- Idempotency API
- Backends
- Framework adapters
- Configuration reference
- Examples
- Node.js support
- Testing
- Contributing
- License
Installation
npm install @hey-amanthakur/coord-box
pnpm add @hey-amanthakur/coord-box
yarn add @hey-amanthakur/coord-boxFramework and backend packages are optional peer dependencies — install only the ones you use:
npm install ioredis # Redis backend only
npm install express # Express adapter only
npm install fastify # Fastify adapter only
npm install koa # Koa adapter only
npm install @nestjs/common @nestjs/core reflect-metadata rxjs # NestJS onlyQuick start
import {
DistributedSemaphore,
IdempotencyRegistry,
MemoryIdempotencyBackend,
MemorySemaphoreBackend,
} from '@hey-amanthakur/coord-box';
// At most 4 slow jobs run at once.
const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend(), {
wait: { maxWaitMs: 5_000 },
});
await semaphore.withPermits('slow-jobs', 1, async () => {
await runSlowJob();
});
// The same operation never runs twice for the same key.
const registry = new IdempotencyRegistry(new MemoryIdempotencyBackend());
const { record, replayed } = await registry.execute('charge:tok_123', charge);The default permit lease is 30 seconds. A holder that crashes simply frees its slots when the lease expires — no stuck permits. Idempotency records live for 24 hours by default.
Core concepts
- Backend decides where coordination state lives and makes every transition atomic.
- Permit is one unit of capacity; a reservation holds
permitsdistinct permit slots. - Lease (TTL) bounds how long a permit is held — the safety net for crashed holders.
- Renewal keeps a reservation alive while a long operation runs, and detects a lost reservation.
- Idempotency key names an operation; the registry ensures the operation runs exactly once per key within its TTL.
Semaphore API
DistributedSemaphore
import {
DistributedSemaphore,
MemorySemaphoreBackend,
} from '@hey-amanthakur/coord-box';
const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend(), {
defaultTtlMs: 10_000, // permit lease duration (default 30s)
wait: { maxWaitMs: 5_000, intervalMs: 100 }, // blocking-acquire defaults
hooks: {
onAcquire: ({ key, permits }) => console.log('acquired', key, permits),
onRelease: ({ key, permits }) => console.log('released', key, permits),
onLost: ({ key, reason }) => console.warn('permits lost', key, reason),
},
});tryAcquire / acquire / withPermits
| Method | Behavior |
| --- | --- |
| tryAcquire(key, permits?, opts?) | One attempt. Returns AcquiredPermits or null when at capacity. |
| acquire(key, permits?, opts?) | Waits (polls) until permits are free or maxWaitMs elapses; throws SemaphoreWaitTimeoutError. |
| withPermits(key, permits, fn, opts?) | acquire → run fn(permits) → release in finally (even if fn throws). |
| count(key) | Current number of active permits for a key. |
permits defaults to 1. When acquiring multiple permits, all are reserved in a single logical transaction — a partial failure rolls the reservation back.
const slot = await semaphore.tryAcquire('drain-queue', 1);
if (slot === null) {
// at capacity — skip this run
}
const guard = await semaphore.acquire('downloads:heavy', 4, { maxWaitMs: 2_000 });
const result = await semaphore.withPermits('drain-queue', 1, async (held) => {
return processNext();
});AcquiredPermits
export interface AcquiredPermits {
readonly key: string;
readonly permits: number; // slots reserved
readonly ids: string[]; // per-slot unique identifiers
readonly autoRenew: boolean;
readonly ended: Promise<{ reason: 'released' | 'expired' | 'aborted'; at: number }>;
isHeld(): Promise<boolean>; // live check against the backend
extend(ttlMs?: number): Promise<void>; // refresh every permit; throws if lost
release(): Promise<boolean>; // idempotent; true if it released active permits
}Leases & auto-renewal
Every permit is a lease: the backend frees it after ttlMs even if nobody releases it. If your operation may run longer than the lease, enable auto-renewal:
await semaphore.withPermits('long-job', 1, async () => {
// every permit's lease is refreshed every ttlMs/3 in the background
}, { ttlMs: 10_000, renew: true });If a renewal fails (the reservation was lost), onLost fires and ended resolves with { reason: 'expired' }. A lost reservation is never silently assumed to be held: check await reservation.isHeld() before committing side effects, or use renewal to detect loss.
Cancellation
const controller = new AbortController();
const job = semaphore.withPermits('x', 1, async () => { /* ... */ }, {
signal: controller.signal,
});
setTimeout(() => controller.abort(), 1_000);- Waiting for permits: aborts with
SemaphoreAbortError. - Already holding: the reservation is released and
endedresolves with{ reason: 'aborted' }.
Hooks
const semaphore = new DistributedSemaphore(backend, {
hooks: {
onAcquire: ({ key, permits }) => {},
onRelease: ({ key, permits }) => {},
onRenew: ({ key, permits }) => {},
onLost: ({ key, reason }) => {}, // 'expired' | 'aborted'
},
});Hook exceptions are isolated by design — a throwing hook never breaks acquisition or release.
Errors
| Error | When |
| --- | --- |
| SemaphoreWaitTimeoutError | Couldn't acquire within maxWaitMs. Carries key, permits, and waitedMs. |
| SemaphoreEndedError | extend() on a reservation that already ended. |
| SemaphoreAbortError | Acquisition cancelled via signal. name === 'AbortError'. |
| SemaphoreError | Base class for all semaphore errors. |
Custom backends
Any object satisfying SemaphoreBackend works:
import type { SemaphoreBackend } from '@hey-amanthakur/coord-box';
const backend: SemaphoreBackend = {
async tryAcquire(key, id, maxPermits, ttlMs) { /* atomic: reserve one slot if capacity remains */ },
async release(key, id) { /* atomic delete-if-owns */ },
async renew(key, id, ttlMs) { /* atomic refresh-if-still-owned-and-unexpired */ },
async isHeld(key, id) { /* does this id still own an unexpired slot? */ },
async count(key) { /* number of unexpired slots */ },
};Each operation must be atomic — that is the entire contract of a distributed semaphore. (For reference, see the Redis Lua scripts in src/redis/index.ts.)
Idempotency API
IdempotencyRegistry
import {
IdempotencyRegistry,
MemoryIdempotencyBackend,
} from '@hey-amanthakur/coord-box';
const registry = new IdempotencyRegistry(new MemoryIdempotencyBackend(), {
defaultTtlMs: 24 * 60 * 60 * 1000, // records live 24h (default)
storeErrors: false, // do not cache failed outcomes (default)
});
const { record, replayed } = await registry.execute(
'charge:tok_123',
async () => {
await charge();
return { id: 'charge-1' };
},
{ ttlMs: 60 * 60 * 1000, waitMs: 30_000, signal, metadata: { amount: 100 } },
);- The first caller for a key becomes the runner and stores its result.
- Concurrent callers with the same key wait for the runner (default 30s) and then replay its result — single-flight.
- Later duplicates replay the stored result instantly.
result.recordis the storedIdempotencyRecord;result.replayedistruewhen the outcome came from storage.
Single-flight semantics
const results = await Promise.all([
registry.execute('webhook:evt_123', run),
registry.execute('webhook:evt_123', run), // waits for the runner, then replays
registry.execute('webhook:evt_123', run), // replays instantly after completion
]);
// run() executed exactly once; the other two results are replayed.Concurrent losers poll the backend until the winner settles or waitMs elapses (then throw IdempotencyWaitTimeoutError). Pass an AbortSignal to cancel the wait (IdempotencyAbortError).
Failed executions
By default a failed execution deletes its record, so a retry of the same key re-runs the operation. Set storeErrors: true (globally or per-execution via option on the registry) to instead cache failures and replay them — duplicates then throw the denormalized error without re-running.
Hooks
const registry = new IdempotencyRegistry(backend, {
hooks: {
onCreated: ({ key }) => {},
onCompleted: ({ key }) => {},
onReplayed: ({ key }) => {},
onFailed: ({ key, error }) => {},
},
});Also available: registry.getRecord(key) to read a stored record and registry.deleteRecord(key) to clear it so the operation can run again.
Errors
| Error | When |
| --- | --- |
| IdempotencyWaitTimeoutError | A concurrent caller waited waitMs for the winner to settle. Carries key and waitedMs. |
| IdempotencyAbortError | The wait was cancelled via signal. name === 'AbortError'. |
| IdempotencyError | Base class for all idempotency errors. |
Custom backends
Any object satisfying IdempotencyBackend works:
import type { IdempotencyBackend } from '@hey-amanthakur/coord-box';
const backend: IdempotencyBackend = {
async create(key, record) { /* atomic set-if-absent */ },
async get(key) { /* record or undefined */ },
async update(key, record) { /* atomic overwrite + reset TTL */ },
async expire(key, ttlMs) { /* delete (0) or reset TTL */ },
};IdempotencyRecord has { key, state: 'in-flight' | 'completed' | 'failed', attempts, createdAt, lastUpdatedAt, expiresAt, result?, error?, metadata? }. normalizeError/denormalizeError are exported for storing/restoring thrown errors.
Backends
In-memory
import {
DistributedSemaphore,
IdempotencyRegistry,
MemoryIdempotencyBackend,
MemorySemaphoreBackend,
} from '@hey-amanthakur/coord-box';
const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend());
const registry = new IdempotencyRegistry(new MemoryIdempotencyBackend());Correct within a single Node process. Swap in Redis (or your own backends) the moment coordination must span processes or machines.
Redis
import Redis from 'ioredis';
import {
createRedisIdempotencyRegistry,
createRedisSemaphore,
} from '@hey-amanthakur/coord-box/redis';
const redis = new Redis({ host: '127.0.0.1' });
const semaphore = createRedisSemaphore(redis, { defaultTtlMs: 10_000 });
const registry = createRedisIdempotencyRegistry(redis, { defaultTtlMs: 24 * 60 * 60 * 1000 });Semaphore operations are atomic Lua scripts: capacity check + slot insert (ZCOUNT/ZADD), and token-verified lease refresh. Permits live in a sorted set keyed by expiry, so crashed holders' slots expire on their own. Idempotency records are stored with a PX expiry. Safe across any number of processes.
const semaphoreBackend = createRedisSemaphoreBackend(redis); // just the backends, if you prefer
const idempotencyBackend = createRedisIdempotencyBackend(redis);Framework adapters
Adapters reserve permits for the duration of the request (released when the response finishes or the connection closes) and/or deduplicate requests by idempotency key.
Express
import express from 'express';
import { DistributedSemaphore, MemorySemaphoreBackend } from '@hey-amanthakur/coord-box';
import { expressSemaphore } from '@hey-amanthakur/coord-box/express';
const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend());
const app = express();
app.post(
'/payments',
expressSemaphore({
semaphore,
permits: 1,
key: (req) => `payments:${req.body.accountId}`,
maxPermits: 10, // the concurrency cap for that key
}),
(req, res) => {
res.locals.coordBoxPermits; // the AcquiredPermits, if you need it
res.json({ ok: true });
},
);Idempotency dedup is a sibling middleware:
import { IdempotencyRegistry, MemoryIdempotencyBackend } from '@hey-amanthakur/coord-box';
import { expressIdempotency } from '@hey-amanthakur/coord-box/express';
const registry = new IdempotencyRegistry(new MemoryIdempotencyBackend());
app.post(
'/payments',
expressIdempotency(registry, { headerName: 'idempotency-key' }),
(req, res) => res.json({ ok: true }),
);
// Duplicate requests replay the first response's status + body.Note: idempotency adapters capture the response via
res.send/res.json. Handlers that stream (res.write) or write the response directly are not captured.
Fastify
import Fastify from 'fastify';
import { DistributedSemaphore, MemorySemaphoreBackend } from '@hey-amanthakur/coord-box';
import { fastifySemaphorePlugin } from '@hey-amanthakur/coord-box/fastify';
const app = Fastify();
const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend());
app.register((instance, _opts, done) => {
instance.register(fastifySemaphorePlugin({
semaphore,
permits: 1,
key: (req) => `payments:${req.body.accountId}`,
maxPermits: 10,
}));
instance.register(fastifyIdempotencyPlugin(registry));
done();
});The reservation is exposed as request.coordBoxPermits. A per-route fastifySemaphoreRoute(options) handler is also exported.
Koa
import Koa from 'koa';
import { DistributedSemaphore, MemorySemaphoreBackend } from '@hey-amanthakur/coord-box';
import { koaSemaphore } from '@hey-amanthakur/coord-box/koa';
const app = new Koa();
const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend());
app.use(koaSemaphore({
semaphore,
permits: 1,
key: (ctx) => `payments:${ctx.request.body.accountId}`,
maxPermits: 10,
}));
app.use(koaIdempotency(registry));The reservation is exposed as ctx.state.coordBoxPermits. koaIdempotency replays ctx.status/ctx.body.
NestJS
import { Controller, Get, UseGuards } from '@nestjs/common';
import { DistributedSemaphore, MemorySemaphoreBackend } from '@hey-amanthakur/coord-box';
import { createSemaphoreGuard, Permits } from '@hey-amanthakur/coord-box/nestjs';
const semaphore = new DistributedSemaphore(new MemorySemaphoreBackend());
@Controller('payments')
class PaymentsController {
@Get()
@UseGuards(createSemaphoreGuard({ semaphore }))
@Permits((req) => `payments:${req.query.accountId}`, { permits: 2, wait: { maxWaitMs: 5_000 } })
pay() {
return 'ok';
}
}The guard acquires the semaphore (using the @Permits metadata), exposes it as request.coordBoxPermits, and releases it when the response finishes.
Idempotency dedup uses a decorator + interceptor:
import { IdempotencyRegistry, MemoryIdempotencyBackend } from '@hey-amanthakur/coord-box';
import { createIdempotencyInterceptor, Idempotency } from '@hey-amanthakur/coord-box/nestjs';
const registry = new IdempotencyRegistry(new MemoryIdempotencyBackend());
// register createIdempotencyInterceptor({ registry }) as an interceptor, then:
@Idempotency() // or @Idempotency({ ttlMs, waitMs })
@Post('charge')
charge() {
return 'ok';
}Duplicates replay the stored value (and captured status) without re-running the handler.
Configuration reference
interface DistributedSemaphoreOptions {
/** Default permit lease duration in ms. Default 30_000. */
defaultTtlMs?: number;
/** Wait defaults for acquire/withPermits. Default { maxWaitMs: 30_000, intervalMs: 200 }. */
wait?: { maxWaitMs?: number; intervalMs?: number };
hooks?: SemaphoreHooks;
}
interface SemaphoreOptions {
ttlMs?: number; // permit lease duration
renew?: boolean | { intervalMs?: number }; // background lease renewal
signal?: AbortSignal; // cancel wait / release held permits
metadata?: unknown; // arbitrary, available to hooks
}
interface SemaphoreWaitOptions extends SemaphoreOptions {
maxWaitMs?: number; // stop waiting after this; Infinity waits forever
intervalMs?: number; // poll interval (jittered)
}
interface IdempotencyRegistryOptions {
defaultTtlMs?: number; // record lifetime. Default 24h.
pollIntervalMs?: number; // concurrent-contender poll interval. Default 100.
storeErrors?: boolean; // cache failed outcomes. Default false.
hooks?: IdempotencyHooks;
}
interface IdempotencyExecuteOptions {
ttlMs?: number; // record TTL for this execution
waitMs?: number; // how long a concurrent loser waits. Default 30_000.
signal?: AbortSignal;
metadata?: unknown;
}Examples
Runnable examples live in examples/ — run any of them with npx tsx:
npx tsx examples/basic.ts # semaphore withPermits, tryAcquire
npx tsx examples/idempotency.ts # exactly-once execution + replay
npx tsx examples/redis.ts # Redis backends (requires a local Redis)
npx tsx examples/adapters.ts # Express / Fastify / KoaNode.js support
| Node line | Status | Supported | | --- | --- | :---: | | 20.x | EOL ~Apr 2026, still widely deployed | ✅ | | 22.x | Active LTS | ✅ | | 24.x | Active LTS (newest LTS) | ✅ | | 26.x | Current | ✅ |
engines.node: ">=20.19.0".
Testing
npm test # run unit + integration tests with tsx + node:test
npm run test:coverage # coverage with gates (lines/functions/statements >= 90, branches >= 85)
npm run typecheck # tsc --noEmit
npm run build # tsup dual ESM/CJS + .d.ts
npm run verify # lint + typecheck + test + buildRedis adapter tests run against ioredis-mock — no live Redis needed.
Contributing
Contributions are welcome and appreciated. Please read the Contributing Guidelines before opening a pull request.
- Bug reports & feature requests → open an issue
- Pull requests → target the
mainbranch; include tests for any new behavior - Discussions & questions → start a discussion
By contributing, you agree that your contributions will be licensed under the MIT License.
License
MIT © 2026 Aman Thakur
