Introduction
Async/await has become the de‑facto way to write asynchronous JavaScript, but misusing it can lead to subtle deadlocks that freeze your Node.js service or browser UI. In this post we’ll explore why these deadlocks happen, how to reproduce them, and step‑by‑step techniques to diagnose and fix them.
Why Async/Await Can Deadlock
-
Blocking the Event Loop – Using
awaitinside a function that also calls a synchronous blocking API (e.g.,fs.readFileSync) prevents the promise from ever resolving. - Circular Promise Dependencies – Two async functions awaiting each other creates a classic deadlock.
-
Improper Use of
Promise.resolve()/new Promise– Forgetting to callresolve/rejectleaves the promise pending forever.
// Example of a circular deadlock
async function a() { await b(); }
async function b() { await a(); } // ❌ both wait on each other
Step‑by‑Step Troubleshooting
1️⃣ Reproduce the Issue Locally
node --inspect-brk app.js
Set a breakpoint right before the await statement and watch the call stack. If it never advances, you likely have a deadlock.
2️⃣ Inspect the Event Loop with node --trace-event
node --trace-event-categories=node.async_hooks --trace-event-file=trace.json app.js
Open trace.json in Chrome’s about:tracing to see which async resources are waiting.
3️⃣ Use async‑hooks for Runtime Visibility
const async_hooks = require('async_hooks');
async_hooks.createHook({
init(asyncId, type, triggerAsyncId) {
console.log(`INIT ${asyncId}: ${type} (triggered by ${triggerAsyncId})`);
},
destroy(asyncId) {
console.log(`DESTROY ${asyncId}`);
}
}).enable();
Look for async IDs that are created but never destroyed – they often indicate a stuck promise.
4️⃣ Refactor Problematic Patterns
-
Avoid mixing sync and async code. Replace
fs.readFileSyncwithawait fs.promises.readFile. - Break circular dependencies by extracting shared logic into a third helper.
// Refactored version without circular wait
async function shared() { /* ... */ }
async function a() { await shared(); }
async function b() { await shared(); }
5️⃣ Add Timeouts for Safety
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Operation timed out")), ms)
);
return Promise.race([promise, timeout]);
}
// Usage
await withTimeout(fetchData(), 5000);
6️⃣ Test with Automated Tools
-
eslint-plugin-promisecatches unhandled promises. -
jestwith fake timers can simulate long‑running async calls.
Real‑World Fixes & Resources
If you’re stuck on a production incident, the following repository contains a ready‑to‑use diagnostic script that instruments your Node process and prints a deadlock report.
Conclusion
Deadlocks in async/await code are rarely magical – they stem from blocking the event loop or creating circular waits. By reproducing the issue, inspecting the event loop, and refactoring the problematic patterns, you can eliminate them quickly. Keep the tooling close, add timeouts, and never mix synchronous I/O with await.
Happy debugging!
Top comments (0)