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

@hey-amanthakur/lock-box

v1.0.1

Published

Lock-Box — a production-grade distributed mutex and lease library for Node.js. Zero runtime dependencies. Pluggable backends (in-memory, Redis), blocking acquire, TTL leases, auto-renewal, AbortSignal cancellation and framework adapters.

Downloads

51

Readme

Lock-Box

A production-grade, framework-agnostic distributed lock & lease library for Node.js

npm version npm downloads license types dependencies node

Zero runtime dependencies · Pluggable backends · TTL leases · Auto-renewal · Blocking acquire · AbortSignal cancellation · Express · Fastify · Koa · NestJS


Overview

Lock-Box is a lightweight, framework-agnostic distributed mutex for Node.js. It serializes access to shared resources — databases, queues, rate-limited external APIs, payment accounts — across processes and machines, with a clean LockBackend interface so you can run it in-process or on real distributed storage (Redis ships out of the box).

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 LockBackend interface. | | TTL leases | Every lock is a lease: if a holder crashes, the lock frees itself after ttlMs. No stuck locks. | | Auto-renewal | Background lease extension so long jobs never lose their lock mid-flight. | | Blocking acquire | withLock/acquire wait (poll) until the lock is free or a timeout elapses. tryAcquire never blocks. | | Cancellation | One AbortSignal aborts the wait and releases a held lease. | | Observability hooks | onAcquire, onRelease, onRenew, onLost — throwing hooks never break the flow. | | Framework adapters | Drop-in middleware for Express, Fastify, Koa, and a decorator + guard for NestJS. | | Dual ESM + CommonJS | Ships both module formats with full TypeScript type definitions. |


When to use it

Lock-Box is for any resource that must be mutated by exactly one actor at a time:

  • Payment / billing operations — never double-charge an account for a concurrent request.
  • Job deduplication — ensure a scheduled job (deployment, report, reindex) isn't already running on another node.
  • Inventory / seat allocation — a final authoritative check before decrementing a shared counter.
  • Idempotent side-effect guards — protect the "once" boundary your idempotency key protects before the side effect.
  • Queue consumption — a leader lease so only one consumer polls a sharded source.

It is not a fit for:

  • Counting concurrency limits — for a "at most N concurrent workers" limit, see Coord-Box (semaphore).
  • HTTP response deduplication — if you want to replay a stored response for duplicate requests, see Coord-Box (idempotency).

Tip: pair a lock with Retry-Box for a fully resilient write path: acquire the lock, then run the operation under retries inside the critical section.


Table of Contents


Installation

npm install @hey-amanthakur/lock-box
pnpm add    @hey-amanthakur/lock-box
yarn add    @hey-amanthakur/lock-box

Framework 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   # NestJS only

Quick start

import { DistributedLock, MemoryLockBackend } from '@hey-amanthakur/lock-box';

const lock = new DistributedLock(new MemoryLockBackend());

await lock.withLock('payments:account-123', async (acquired) => {
  // exactly one process runs this at a time
  await charge();
});

The default lease is 30 seconds. A holder that crashes simply releases the lock when the lease expires — no stuck locks.


Core concepts

  • Backend decides where the lock lives and makes acquisition atomic.
  • Lease (TTL) bounds how long a lock is held — the safety net for crashed holders.
  • Token uniquely identifies each acquisition; only the owning token can release or extend.
  • Renewal keeps the lease alive while a long operation runs, and detects a lost lock.

Core API

DistributedLock

import {
  DistributedLock,
  MemoryLockBackend,
} from '@hey-amanthakur/lock-box';

const lock = new DistributedLock(new MemoryLockBackend(), {
  defaultTtlMs: 10_000,                         // lease duration (default 30s)
  wait: { maxWaitMs: 5_000, intervalMs: 100 },  // blocking-acquire defaults
  hooks: {
    onAcquire: ({ key }) => console.log('locked', key),
    onRelease: ({ key }) => console.log('released', key),
    onLost: ({ key, reason }) => console.warn('lock lost', key, reason),
  },
});

tryAcquire / acquire / withLock

| Method | Behavior | | --- | --- | | tryAcquire(key, opts?) | One attempt. Returns an AcquiredLock or null if held. | | acquire(key, opts?) | Waits (polls) until free or maxWaitMs elapses; throws LockWaitTimeoutError. | | withLock(key, fn, opts?) | acquire → run fn(lock) → release in finally (even if fn throws). | | release(key, token) | Convenience: release by key + token. |

const lock = await lock.tryAcquire('queue:leader');
if (lock === null) {
  // someone else is leader — skip
}

const guard = await lock.acquire('inventory:SKU-1', { maxWaitMs: 2_000 });

const result = await lock.withLock('account:42', async (held) => {
  return deduct();
});

AcquiredLock

export interface AcquiredLock {
  readonly key: string;
  readonly token: string;      // unique per acquisition
  readonly expiresAt: number;  // epoch ms
  readonly autoRenew: boolean;
  readonly ended: Promise<{ reason: 'released' | 'expired' | 'aborted'; at: number }>;
  isHeld(): Promise<boolean>;  // live check against the backend
  extend(ttlMs?: number): Promise<Date>;
  release(): Promise<boolean>; // idempotent; true if it released an active lease
}

Leases & auto-renewal

Every lock 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 lock.withLock('long-job', async () => {
  // lease is refreshed every ttlMs/3 in the background
}, { ttlMs: 10_000, renew: true });

If a renewal fails (the lease was lost — e.g. backend hiccup or another actor), onLost fires and lock.ended resolves with { reason: 'expired' }. A lost lock is never silently assumed to be held: check await lock.isHeld() before committing side effects, or use renewal to detect loss.

Cancellation

const controller = new AbortController();

const job = lock.withLock('x', async () => { /* ... */ }, { signal: controller.signal });
setTimeout(() => controller.abort(), 1_000);

// aborts the wait AND releases an already-held lease
  • Waiting on a lock: aborts with LockAbortError.
  • Already holding: the lease is released and ended resolves with { reason: 'aborted' }.

Hooks

const lock = new DistributedLock(backend, {
  hooks: {
    onAcquire: ({ key, token }) => {},
    onRelease: ({ key, token }) => {},
    onRenew: ({ key, token, expiresAt }) => {},
    onLost: ({ key, token, reason }) => {}, // 'expired' | 'aborted'
  },
});

Hook exceptions are isolated by design — a throwing hook never breaks acquisition or release.

Errors

| Error | When | | --- | --- | | LockWaitTimeoutError | Couldn't acquire within maxWaitMs. Carries key and waitedMs. | | LockEndedError | extend()/release() on a lease that already ended. | | LockAbortError | Acquisition cancelled via signal. name === 'AbortError'. | | LockError | Base class for all lock errors. |

Custom backends

Any object satisfying LockBackend works:

import type { LockBackend } from '@hey-amanthakur/lock-box';

const backend: LockBackend = {
  async acquire(key, token, ttlMs) { /* atomic set-if-not-exists */ },
  async extend(key, token, ttlMs) { /* atomic compare-and-refresh */ },
  async release(key, token) { /* atomic delete-if-matches */ },
  async get(key) { /* current token or undefined */ },
};

The four operations must each be atomic — that is the entire contract of a distributed lock. (For reference, see src/redis/backend.ts.)


Backends

In-memory

import { DistributedLock, MemoryLockBackend } from '@hey-amanthakur/lock-box';

const lock = new DistributedLock(new MemoryLockBackend());

Correct within a single Node process. Swap in Redis (or your own backend) the moment locks must coordinate across processes or machines.

Redis

import Redis from 'ioredis';
import { createRedisLock } from '@hey-amanthakur/lock-box/redis';

const redis = new Redis({ host: '127.0.0.1' });
const lock = createRedisLock(redis, { defaultTtlMs: 10_000 });

Every operation is an atomic Lua script (SET NX PX for acquire, token-verified PEXPIRE/DEL for extend/release), so the ownership check and the write happen in one step — safe across any number of processes.

const backend = createRedisLockBackend(redis); // just the backend, if you prefer

Framework adapters

Adapters acquire the lock, expose it to your handler, and release it when the response finishes (or the connection closes).

Express

import express from 'express';
import { DistributedLock, MemoryLockBackend } from '@hey-amanthakur/lock-box';
import { expressLock } from '@hey-amanthakur/lock-box/express';

const lock = new DistributedLock(new MemoryLockBackend());
const app = express();

app.post(
  '/payments',
  expressLock({ lock, key: (req) => `payments:${req.body.accountId}` }),
  (req, res) => {
    res.locals.lock; // the AcquiredLock, if you need it
    res.json({ ok: true });
  },
);

Fastify

import Fastify from 'fastify';
import { DistributedLock, MemoryLockBackend } from '@hey-amanthakur/lock-box';
import { fastifyLockPlugin } from '@hey-amanthakur/lock-box/fastify';

const app = Fastify();
const lock = new DistributedLock(new MemoryLockBackend());

app.register((instance, _opts, done) => {
  fastifyLockPlugin({ lock, key: (req) => `payments:${req.body.accountId}` })(instance);
  done();
});

The acquired lock is exposed as request.lock.

Koa

import Koa from 'koa';
import { DistributedLock, MemoryLockBackend } from '@hey-amanthakur/lock-box';
import { koaLock } from '@hey-amanthakur/lock-box/koa';

const app = new Koa();
const lock = new DistributedLock(new MemoryLockBackend());

app.use(koaLock({ lock, key: (ctx) => `payments:${ctx.request.body.accountId}` }));

The acquired lock is exposed as ctx.state.lock.

NestJS

import { Controller, Get, UseGuards } from '@nestjs/common';
import { DistributedLock, MemoryLockBackend } from '@hey-amanthakur/lock-box';
import { Lock, createLockGuard } from '@hey-amanthakur/lock-box/nestjs';

const lock = new DistributedLock(new MemoryLockBackend());

@Controller('payments')
class PaymentsController {
  @Get()
  @UseGuards(createLockGuard({ lock }))
  @Lock((req) => `payments:${req.query.accountId}`)  // or @Lock('payments:fixed-key')
  pay() {
    return 'ok';
  }
}

The guard acquires the lock (using the @Lock metadata), exposes it as request.lockBoxLock, and releases it when the response finishes.


Configuration reference

interface DistributedLockOptions {
  /** Default lease duration in ms. Default 30_000. */
  defaultTtlMs?: number;
  /** Wait defaults for acquire/withLock. Default { maxWaitMs: 30_000, intervalMs: 200 }. */
  wait?: { maxWaitMs?: number; intervalMs?: number };
  hooks?: LockHooks;
}

interface LockOptions {
  ttlMs?: number;                             // lease duration
  renew?: boolean | { intervalMs?: number };  // background lease renewal
  signal?: AbortSignal;                       // cancel wait / release held lock
  metadata?: unknown;                         // arbitrary, available to hooks
}

interface WaitOptions extends LockOptions {
  maxWaitMs?: number;   // stop waiting after this; Infinity waits forever
  intervalMs?: number;  // poll interval
}

Examples

Runnable examples live in examples/ — run any of them with npx tsx:

npx tsx examples/basic.ts     # withLock, mutual exclusion
npx tsx examples/redis.ts     # Redis backend (requires a local Redis)
npx tsx examples/adapters.ts  # Express / Fastify / Koa

Node.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 + build

Redis 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 requestsopen an issue
  • Pull requests → target the main branch; include tests for any new behavior
  • Discussions & questionsstart a discussion

By contributing, you agree that your contributions will be licensed under the MIT License.


License

MIT © 2026 Aman Thakur