100% test coverage · zero dependencies

Async race conditions,
finally solved.

A ~500 byte keyed mutex for JS & TS. Sequential per key, parallel across keys — with zero configuration.

$ npm install async-mutex-lite
~500B
gzip size
0
dependencies
100%
test coverage
The problem

JS is single-threaded.
Race conditions aren't.

Without mutex — dangerous
// ❌ 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 💸
})
With async-mutex-lite — safe
// ✅ 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

mutex("user:1")
taskA
taskB
taskC
sequential
mutex("user:2")
taskD
parallel — independent

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.

Quick start

Up and running
in 30 seconds.

$ npm install async-mutex-lite
basic-usage.ts
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()
})
Use cases

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)
)
API Reference

One function.
Three parameters.

type signature
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
"continue" default

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")
"stop"

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")
Real world

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 } })
  })
}
Comparison

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
FAQ

Questions answered.

Is this production ready?
Yes. The library has no dependencies, a very small surface area, and 100% test coverage. It's been designed for production use from day one.
Does it work in serverless environments?
Yes — with an important caveat. Each serverless instance has its own memory. Mutex works only when conflicting requests hit the same instance. For cross-instance coordination you'll need an external lock (e.g., Redis).
Is execution order (FIFO) guaranteed?
Yes. Tasks are executed in exactly the order they were scheduled. The library uses a simple promise chain — no priority queue complexity.
Does it support CommonJS and ESM?
Yes. The package ships both .js (ESM) and .cjs (CommonJS) builds with full TypeScript type declarations.
Can memory leak if keys accumulate?
No. After all tasks for a key complete, the internal entry is cleaned up automatically. Memory usage stays proportional to the number of active (currently running or waiting) task queues only.

Ready to ship
race-condition-free?

Install in seconds. Zero config. Works everywhere.

$ npm install async-mutex-lite