Tools
Tools: Solving Async Race Conditions in JavaScript with a 500B Library
2026-03-05
0 views
admin
Solving Async Race Conditions in JavaScript with a 500B Library ## The Problem: Async Race Conditions ## The Solution: Keyed Async Mutex ## How It Works Internally ## Basic Usage ## Real World Use Cases ## Prevent Double Charges ## Prevent Duplicate Webhook Processing ## Cache Stampede Protection ## Inventory Updates ## Error Handling Strategy ## Default: "continue" ## "stop" Strategy ## When Should You Use It? ## When You Should NOT Use It ## Why async-mutex-lite? ## Serverless Note ## Try It Out JavaScript is single-threaded, but race conditions still happen. Any time multiple asynchronous operations modify the same resource, you risk inconsistent state. To solve this problem, I built a tiny utility: async-mutex-lite — a keyed async mutex for JavaScript & TypeScript. Consider a typical checkout endpoint. Two requests arrive at nearly the same time for the same user. What happens if both requests read the balance before either deducts it? Now the balance has been deducted twice. This is a classic race condition. async-mutex-lite ensures tasks with the same key run sequentially, while tasks with different keys run in parallel. Now the execution becomes: Requests for the same user are queued. Requests for different users still run concurrently. Instead of maintaining a traditional queue, the library uses Promise chaining. Each key has its own promise chain. Benefits of this approach: Using the mutex is simple. Synchronous functions also work: The library supports configurable error handling. The queue continues even if a task fails. Stop all queued tasks when an error occurs. This is useful for transaction-like workflows. Mutexes are unnecessary for: A mutex only helps when multiple async tasks modify the same resource. Many mutex libraries exist, but most of them are heavier than necessary. Goals of this library: This mutex works within a single process. In serverless environments: If you need cross-instance locking, use: If you're dealing with async race conditions in JavaScript, this tiny utility might save you a lot of headaches. ⭐ If you find it useful, consider giving the repository a star. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK:
npm install async-mutex-lite Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
npm install async-mutex-lite COMMAND_BLOCK:
npm install async-mutex-lite COMMAND_BLOCK:
app.post("/checkout", async (req) => { const balance = await getBalance(req.userId) if (balance >= req.amount) { await deductBalance(req.userId, req.amount) await createOrder(req.userId) }
}) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
app.post("/checkout", async (req) => { const balance = await getBalance(req.userId) if (balance >= req.amount) { await deductBalance(req.userId, req.amount) await createOrder(req.userId) }
}) COMMAND_BLOCK:
app.post("/checkout", async (req) => { const balance = await getBalance(req.userId) if (balance >= req.amount) { await deductBalance(req.userId, req.amount) await createOrder(req.userId) }
}) COMMAND_BLOCK:
Request A -> balance = 100
Request B -> balance = 100 Request A deducts
Request B deducts Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
Request A -> balance = 100
Request B -> balance = 100 Request A deducts
Request B deducts COMMAND_BLOCK:
Request A -> balance = 100
Request B -> balance = 100 Request A deducts
Request B deducts COMMAND_BLOCK:
import { mutex } from "async-mutex-lite" 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) } })
}) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import { mutex } from "async-mutex-lite" 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) } })
}) COMMAND_BLOCK:
import { mutex } from "async-mutex-lite" 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) } })
}) COMMAND_BLOCK:
checkout:user1 -> taskA -> taskB -> taskC (sequential)
checkout:user2 -> taskD (parallel) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
checkout:user1 -> taskA -> taskB -> taskC (sequential)
checkout:user2 -> taskD (parallel) COMMAND_BLOCK:
checkout:user1 -> taskA -> taskB -> taskC (sequential)
checkout:user2 -> taskD (parallel) CODE_BLOCK:
mutex("user:1", taskA) ─┐
mutex("user:1", taskB) ─┼─► taskA → taskB → taskC
mutex("user:1", taskC) ─┘ mutex("user:2", taskD) ───► taskD Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
mutex("user:1", taskA) ─┐
mutex("user:1", taskB) ─┼─► taskA → taskB → taskC
mutex("user:1", taskC) ─┘ mutex("user:2", taskD) ───► taskD CODE_BLOCK:
mutex("user:1", taskA) ─┐
mutex("user:1", taskB) ─┼─► taskA → taskB → taskC
mutex("user:1", taskC) ─┘ mutex("user:2", taskD) ───► taskD COMMAND_BLOCK:
import { mutex } from "async-mutex-lite" const result = await mutex("my-key", async () => { const data = await fetchSomething() return data
}) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import { mutex } from "async-mutex-lite" const result = await mutex("my-key", async () => { const data = await fetchSomething() return data
}) COMMAND_BLOCK:
import { mutex } from "async-mutex-lite" const result = await mutex("my-key", async () => { const data = await fetchSomething() return data
}) COMMAND_BLOCK:
const value = await mutex("my-key", () => { return 42
}) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
const value = await mutex("my-key", () => { return 42
}) COMMAND_BLOCK:
const value = await mutex("my-key", () => { return 42
}) COMMAND_BLOCK:
await mutex(`wallet:${userId}`, async () => { await processPayment(userId, amount)
}) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
await mutex(`wallet:${userId}`, async () => { await processPayment(userId, amount)
}) COMMAND_BLOCK:
await mutex(`wallet:${userId}`, async () => { await processPayment(userId, amount)
}) COMMAND_BLOCK:
await mutex(`webhook:${webhookId}`, async () => { await processWebhook(webhookId)
}) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
await mutex(`webhook:${webhookId}`, async () => { await processWebhook(webhookId)
}) COMMAND_BLOCK:
await mutex(`webhook:${webhookId}`, async () => { await processWebhook(webhookId)
}) COMMAND_BLOCK:
async function getUser(userId: string) { if (cache.has(userId)) return cache.get(userId) 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 })
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
async function getUser(userId: string) { if (cache.has(userId)) return cache.get(userId) 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 })
} COMMAND_BLOCK:
async function getUser(userId: string) { if (cache.has(userId)) return cache.get(userId) 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 })
} COMMAND_BLOCK:
await mutex(`product:${productId}`, async () => { const stock = await getStock(productId) if (stock > 0) { await decrementStock(productId) }
}) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
await mutex(`product:${productId}`, async () => { const stock = await getStock(productId) if (stock > 0) { await decrementStock(productId) }
}) COMMAND_BLOCK:
await mutex(`product:${productId}`, async () => { const stock = await getStock(productId) if (stock > 0) { await decrementStock(productId) }
}) COMMAND_BLOCK:
await mutex("key", () => { throw new Error("failed")
}).catch(console.error) await mutex("key", () => { console.log("this task still runs")
}) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
await mutex("key", () => { throw new Error("failed")
}).catch(console.error) await mutex("key", () => { console.log("this task still runs")
}) COMMAND_BLOCK:
await mutex("key", () => { throw new Error("failed")
}).catch(console.error) await mutex("key", () => { console.log("this task still runs")
}) COMMAND_BLOCK:
await mutex("key", () => { throw new Error("failed")
}, { onError: "stop" }).catch(console.error) await mutex("key", () => { console.log("this will never run")
}) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
await mutex("key", () => { throw new Error("failed")
}, { onError: "stop" }).catch(console.error) await mutex("key", () => { console.log("this will never run")
}) COMMAND_BLOCK:
await mutex("key", () => { throw new Error("failed")
}, { onError: "stop" }).catch(console.error) await mutex("key", () => { console.log("this will never run")
}) COMMAND_BLOCK:
npm install async-mutex-lite Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
npm install async-mutex-lite COMMAND_BLOCK:
npm install async-mutex-lite - double charging a customer
- processing the same webhook twice
- creating duplicate database records
- cache stampedes
- concurrent file writes - 🔒 Sequential execution per key
- ⚡ Parallel execution across different keys
- 📦 ~400–600 bytes gzip
- 🧩 Zero dependencies
- 🟦 Full TypeScript support - minimal code
- extremely small bundle size
- FIFO execution
- automatic cleanup
- no memory leaks - financial transactions
- idempotent APIs
- webhook processing
- per-user locking
- cache rebuilding
- inventory updates
- sequential file writes - stateless operations
- pure read queries
- CPU-bound workloads
- operations that are already sequential - minimal bundle size
- modern TypeScript support
- zero dependencies - each instance has its own memory
- mutex only applies inside that instance - Redis locks
- database transactions
- distributed lock systems - https://github.com/denycode-dev/async-mutex-lite - https://npmjs.com/package/async-mutex - https://async-mutex-lite.vercel.app/
how-totutorialguidedev.toaiserverjavascriptdatabasegitgithub