A small, zero-dependency library for distributed locking. Supports Redis (ioredis), MongoDB, and in-memory backends. You provide your own client instances — locco has no runtime dependencies.
- Three backends — Redis, MongoDB, and in-memory (for testing)
- Zero runtime dependencies — adapters use duck-typed interfaces, no client packages bundled
- Atomic operations — Redis uses Lua scripts; MongoDB uses upsert with unique indexes
- Automatic release — pass a callback to
acquire()and the lock is released in afinallyblock - Configurable retry — fixed delay, total time cap, or fully custom delay function
- Fencing tokens — every lock carries a unique value; release/extend only succeed if the value matches
- Dual package — ships both ESM and CommonJS builds with full TypeScript declarations
npm i @kontsedal/loccoLock a resource, do work, then release manually. Always release in a finally block to avoid dangling locks.
import { Locker, IoRedisAdapter } from "@kontsedal/locco";
import Redis from "ioredis";
const adapter = new IoRedisAdapter({ client: new Redis() });
const locker = new Locker({
adapter,
retrySettings: { retryDelay: 200, retryTimes: 10 },
});
const lock = await locker.lock("user:123", 3000).acquire();
try {
// critical section
await lock.extend(2000); // need more time? extend the TTL
// continue working
} finally {
await lock.release();
}Pass a callback to acquire() and the lock is released automatically when the callback finishes (or throws).
import { Locker, MongoAdapter } from "@kontsedal/locco";
import { MongoClient } from "mongodb";
const adapter = new MongoAdapter({
client: new MongoClient("mongodb://localhost:27017"),
});
const locker = new Locker({
adapter,
retrySettings: { retryDelay: 200, retryTimes: 10 },
});
const result = await locker.lock("user:123", 3000).acquire(async (lock) => {
// critical section — lock is auto-released when this function returns
await lock.extend(2000);
return { success: true };
});
console.log(result); // { success: true }import { Locker, InMemoryAdapter } from "@kontsedal/locco";
const locker = new Locker({
adapter: new InMemoryAdapter(),
retrySettings: { retryDelay: 50, retryTimes: 5 },
});
const lock = await locker.lock("resource:1", 1000).acquire();
// ...
await lock.release();Factory that creates Lock instances bound to a backend adapter and default retry settings.
const locker = new Locker({ adapter, retrySettings });Constructor parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
adapter |
ILockAdapter |
Yes | Backend adapter (Redis, MongoDB, or in-memory) |
retrySettings |
RetrySettings |
Yes | Default retry behavior for all locks created by this locker |
Methods:
Creates a Lock instance. Does not acquire the lock — call .acquire() on the returned object.
key— a non-empty string identifying the resource to lockttl— time to live in milliseconds (positive integer)
const lock = locker.lock("orders:456", 5000);Represents a single lock on a resource. Created via locker.lock().
Acquires the lock. Retries according to the retry settings if the resource is already locked.
// Without callback — returns the Lock; you must release manually
const lock = await locker.lock("key", 3000).acquire();
// With callback — auto-releases when the callback finishes
const result = await locker.lock("key", 3000).acquire(async (lock) => {
return doWork();
});Releases the lock. By default, silently succeeds even if the lock has already expired or was taken by another process. Pass { throwOnFail: true } to throw on failure.
await lock.release();
await lock.release({ throwOnFail: true }); // throws LockReleaseError on failureExtends the lock's TTL by the given number of milliseconds from now. Throws LockExtendError if the lock is no longer valid.
await lock.extend(5000); // lock is now valid for 5 more secondsReturns true if this specific lock is still valid in the backend.
if (await lock.isLocked()) {
// still holding the lock
}Returns a new Lock with overridden retry settings. Must be called before acquire().
const lock = await locker
.lock("key", 3000)
.setRetrySettings({ retryDelay: 100, retryTimes: 50 })
.acquire();| Property | Type | Description |
|---|---|---|
key |
string |
The resource key |
ttl |
number |
The lock TTL in milliseconds |
uniqueValue |
string |
The fencing token (hex string) |
retrySettings |
RetrySettings |
Current retry settings |
Controls how acquire() retries when a resource is already locked.
| Parameter | Type | Description |
|---|---|---|
retryDelay |
number |
Milliseconds between retries (positive integer) |
retryTimes |
number |
Maximum number of retry attempts (positive integer) |
totalTime |
number |
Hard cap on total retry duration in milliseconds |
retryDelayFn |
function |
Custom delay function (see below). Mutually exclusive with retryDelay |
When using a fixed delay, both retryDelay and retryTimes are required. You can optionally add totalTime as an additional safeguard.
Custom delay function:
const locker = new Locker({
adapter: new InMemoryAdapter(),
retrySettings: {
retryDelayFn: ({ attemptNumber, startedAt, previousDelay, settings, stop }) => {
// Exponential backoff
return Math.min(2 ** attemptNumber * 50, 5000);
},
},
});The function receives:
| Parameter | Type | Description |
|---|---|---|
attemptNumber |
number |
Zero-based attempt index |
startedAt |
number |
Timestamp (ms) when retrying began |
previousDelay |
number |
The delay returned on the previous attempt |
settings |
RetrySettings |
The full retry settings object |
stop |
() => void |
Call to stop retrying immediately (throws RetryError) |
All adapters implement the ILockAdapter interface. You can write your own by implementing four methods: createLock, releaseLock, extendLock, and isValidLock.
import { IoRedisAdapter } from "@kontsedal/locco";
import Redis from "ioredis";
const adapter = new IoRedisAdapter({ client: new Redis() });| Parameter | Type | Required | Description |
|---|---|---|---|
client |
ioredis-compatible | Yes | Any object with set, get, and defineCommand methods |
The adapter registers two Lua commands (releaseLock and extendLock) on the client at construction time to ensure atomic compare-and-delete/extend operations.
import { MongoAdapter } from "@kontsedal/locco";
import { MongoClient } from "mongodb";
const adapter = new MongoAdapter({
client: new MongoClient("mongodb://localhost:27017"),
dbName: "my-app", // optional
locksCollectionName: "my-locks", // optional, defaults to "locco-locks"
});| Parameter | Type | Required | Description |
|---|---|---|---|
client |
mongodb-compatible | Yes | Any object matching the MongoLikeClient interface |
dbName |
string |
No | Database name passed to client.db() |
locksCollectionName |
string |
No | Collection name (default: "locco-locks") |
Indexes are created lazily and idempotently on first use.
import { InMemoryAdapter } from "@kontsedal/locco";
const adapter = new InMemoryAdapter();No configuration needed. Uses a Map internally with setTimeout for TTL expiration. Timers call .unref() so they don't prevent Node.js from exiting.
Useful for unit tests where you don't want external dependencies.
All errors extend LoccoError, which extends Error. You can catch specific error types using instanceof.
import {
LoccoError,
LockCreateError,
LockReleaseError,
LockExtendError,
RetryError,
ValidationError,
} from "@kontsedal/locco";
try {
const lock = await locker.lock("key", 3000).acquire();
} catch (error) {
if (error instanceof RetryError) {
// could not acquire the lock within the retry limits
} else if (error instanceof ValidationError) {
// invalid parameters (e.g., negative TTL, missing retryDelay)
}
}| Error class | When it's thrown |
|---|---|
LockCreateError |
Backend reports the resource is already locked (this is the error retry catches and retries on) |
LockReleaseError |
Lock is expired or owned by another process (release({ throwOnFail: true })) |
LockExtendError |
Lock is expired or owned by another process when extending |
RetryError |
Retry limit, total time, or manual stop() exceeded during acquire() |
ValidationError |
Invalid parameters passed to any public method |
Lock creation uses the Redis SET command with two options:
- NX — only set the key if it does not already exist
- PX — set an expiration in milliseconds
SET <key> <uniqueValue> PX <ttl> NX
If the key already exists (another lock is active), SET returns null and the acquire fails (triggering a retry). If the key doesn't exist, it's created atomically with the given TTL.
Release and extend use Lua scripts to atomically check the stored value and either delete the key or reset its TTL. This guarantees that a lock can only be released or extended by the process that created it.
Locks are stored in a collection with three fields: key, uniqueValue, and expireAt. A unique index on key prevents duplicate locks.
To create a lock, the adapter uses updateOne with upsert: true:
collection.updateOne(
{ key, expireAt: { $lt: new Date() } }, // only match expired locks
{ $set: { key, uniqueValue, expireAt } },
{ upsert: true }
);If a valid (non-expired) lock exists, the filter doesn't match it, so MongoDB tries to insert a new document — which fails due to the unique index. This makes lock creation atomic without transactions.
A TTL index on expireAt with expireAfterSeconds: 0 lets MongoDB automatically clean up expired lock documents.
Release and extend use the same pattern: they match on both key and uniqueValue to ensure only the lock owner can modify or delete the lock.
- Node.js >= 18
- Redis adapter: ioredis (or any compatible client)
- MongoDB adapter: mongodb driver (or any compatible client)
MIT