// ❌ Two requests arrive simultaneously
app.post("/checkout", async (req) => {
// Both read $100 before either deducts
const balance = await getBalance(req.userId)
if (balance >= req.amount) {
await deductBalance(req.userId, req.amount)
await createOrder(req.userId)
}
// Result: balance deducted twice 💸
})
Async race conditions,
finally solved.
A ~500 byte keyed mutex for JS & TS. Sequential per key, parallel across keys — with zero configuration.
JS is single-threaded.
Race conditions aren't.
// ✅ Requests for same user are queued
app.post("/checkout", async (req) => {
await mutex(`checkout:${req.userId}`, async () => {
const balance = await getBalance(req.userId)
if (balance >= req.amount) {
await deductBalance(req.userId, req.amount)
await createOrder(req.userId)
}
})
// Result: safe, sequential per user ✅
})
How it works — Promise chaining
Each key maintains its own promise chain. New tasks always wait for the previous task in the chain to finish. Memory is cleaned automatically after all tasks complete — no memory leaks.
Up and running
in 30 seconds.
import { mutex } from "async-mutex-lite"
// Async function — returns the task's return value
const result = await mutex("my-key", async () => {
const data = await fetchSomething()
return data
})
// Sync functions work too
const value = await mutex("my-key", () => {
return 42
})
// TypeScript generic inference — no casting needed
const user: User | undefined = await mutex("fetch-user", async () => {
return await db.user.findFirst()
})
Built for real-world
async problems.
Financial Transactions
Prevent double-charges or negative balances when multiple requests hit the same user account.
await mutex(`wallet:${userId}`, () =>
processPayment(userId, amount)
)
Webhook Deduplication
Idempotent webhook processing — even when the same event is delivered twice.
await mutex(`webhook:${event.id}`, () =>
processWebhook(event)
)
Cache Stampede
Prevent thundering herd — only one request populates the cache, others wait and reuse.
return mutex(`cache:${userId}`, async () => {
if (cache.has(userId)) return cache.get(userId)
const user = await db.findUser(userId)
cache.set(userId, user)
return user
})
Inventory Updates
Prevent overselling by sequencing all stock reads/writes for the same product.
await mutex(`product:${productId}`, async () => {
const stock = await getStock(productId)
if (stock > 0) await decrementStock(productId)
})
File Write Operations
Serialize concurrent writes to a shared file — no data corruption or partial writes.
await mutex("log-file", () =>
fs.appendFile("app.log", logLine)
)
Per-User Rate Limiting
Throttle external API calls on a per-user basis without complex queueing infrastructure.
await mutex(`api-call:${userId}`, () =>
callExternalAPI(userId)
)
One function.
Three parameters.
function mutex<T>(
key: string,
task: () => Promise<T> | T,
options?: MutexOptions
): Promise<T | undefined>
interface MutexOptions {
onError?: "continue" | "stop" // default: "continue"
}
| Parameter | Type | Required |
|---|---|---|
| key | string | required |
| task | () => Promise<T> | T | required |
| options | MutexOptions | optional |
Queue continues even if a task fails. Use when failures shouldn't block other tasks.
const t1 = mutex("key", () => {
throw new Error("failed")
}).catch(console.error)
// This STILL runs ✅
const t2 = mutex("key", () => "ok")
All pending tasks are cancelled on failure. For all-or-nothing transactional workflows.
const t1 = mutex("key", () => {
throw new Error("failed")
}, { onError: "stop" })
// This will NOT run ❌
const t2 = mutex("key", () => "skipped")
Production-ready
code examples.
Express.js — Checkout API
Prevent double-charge in concurrent requests
import express from "express"
import { mutex } from "async-mutex-lite"
const app = express()
app.post("/checkout", async (req, res) => {
const { userId, productId, quantity } = req.body
try {
await mutex(`checkout:${userId}`, async () => {
const [balance, stock] = await Promise.all([
getBalance(userId),
getStock(productId),
])
if (balance < req.body.total) throw new Error("Insufficient balance")
if (stock < quantity) throw new Error("Insufficient stock")
await Promise.all([
deductBalance(userId, req.body.total),
deductStock(productId, quantity),
createOrder({ userId, productId, quantity }),
])
})
res.json({ success: true })
} catch (err) {
res.status(400).json({ error: err.message })
}
})
Next.js — Prevent Duplicate Submission
Email subscription with race-safe deduplication
import { mutex } from "async-mutex-lite"
export async function POST(req: Request) {
const { email } = await req.json()
await mutex(`subscribe:${email}`, async () => {
const exists = await db.user.findUnique({ where: { email } })
if (exists) throw new Error("Email already registered")
await db.user.create({ data: { email } })
await sendWelcomeEmail(email)
})
return Response.json({ message: "Subscription successful!" })
}
Webhook — Idempotent Processing
Handle duplicate delivery gracefully
import { mutex } from "async-mutex-lite"
async function handleWebhook(event: WebhookEvent) {
await mutex(`webhook:${event.id}`, async () => {
const alreadyProcessed = await db.webhook.findUnique({
where: { id: event.id }
})
if (alreadyProcessed) return
await processEvent(event)
await db.webhook.create({ data: { id: event.id } })
})
}
Smallest footprint.
Most features.
| Library | Size | Keyed Lock | Error Strategy | TypeScript |
|---|---|---|---|---|
| async-lock | ~5 KB | Partial | ||
| async-mutex | ~3 KB | |||
| await-lock | ~1 KB | |||
|
async-mutex-lite
← you are here
|
~500B |
Questions answered.
Is this production ready?
Does it work in serverless environments?
Is execution order (FIFO) guaranteed?
Does it support CommonJS and ESM?
.js (ESM) and .cjs (CommonJS) builds with full TypeScript type declarations.
Can memory leak if keys accumulate?
Ready to ship
race-condition-free?
Install in seconds. Zero config. Works everywhere.