You've been writing async/await for years. You know how Promises work. Yet somehow, that production bug at 3 AM traced back to an async function you wrote yourself. Sound familiar?
The truth is, async/await's clean syntax hides profound complexity. It makes asynchronous code look synchronous, which is precisely why we keep falling into the same traps. This guide dissects the pitfalls that still trip up developers in 2026—from subtle memory leaks to race conditions that only manifest in production.
We'll go beyond the basics. This is a troubleshooting guide for developers who already know async/await but want to truly master it.
The Sequential Execution Trap: Why Your Async Code Is Secretly Slow
This is the most common performance killer in async code. Consider this seemingly innocent function:
async function fetchUserData(userIds) {
const users = [];
for (const id of userIds) {
const user = await fetchUser(id);
users.push(user);
}
return users;
}
If you have 10 users and each fetchUser takes 100ms, this function takes 1 second. But it could take 100ms if executed correctly.
The Problem
await pauses execution until the Promise resolves. Inside a loop, this means each request waits for the previous one to complete. We've serialized inherently parallel operations.
The Solution: Promise.all
async function fetchUserData(userIds) {
const userPromises = userIds.map(id => fetchUser(id));
const users = await Promise.all(userPromises);
return users;
}
Now all requests fire simultaneously, and we wait only for the slowest one.
The Nuance: When Sequential Is Correct
Sometimes you need sequential execution:
async function processPayments(payments) {
const results = [];
for (const payment of payments) {
// Each payment depends on the previous balance
const result = await processPayment(payment);
results.push(result);
}
return results;
}
The key is intentionality. Don't accidentally serialize parallel work.
The Advanced Pattern: Controlled Concurrency
Promise.all isn't always the answer. If you're making 1000 API calls, you'll overwhelm the server. Use controlled concurrency:
async function fetchWithConcurrency(urls, concurrency = 5) {
const results = [];
const executing = new Set();
for (const url of urls) {
const promise = fetch(url).then(response => {
executing.delete(promise);
return response.json();
});
executing.add(promise);
results.push(promise);
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
Or use the modern Promise.withResolvers() with a semaphore pattern:
class Semaphore {
#queue = [];
#running = 0;
constructor(concurrency) {
this.concurrency = concurrency;
}
async acquire() {
if (this.#running >= this.concurrency) {
const { promise, resolve } = Promise.withResolvers();
this.#queue.push(resolve);
await promise;
}
this.#running++;
}
release() {
this.#running--;
if (this.#queue.length > 0) {
const next = this.#queue.shift();
next();
}
}
async run(fn) {
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
}
// Usage
const semaphore = new Semaphore(5);
const results = await Promise.all(
urls.map(url => semaphore.run(() => fetch(url)))
);
The Unhandled Rejection Catastrophe
In Node.js 22+, unhandled Promise rejections terminate the process by default. This one change has crashed more production servers than any other async issue.
The Silent Killer
async function riskyOperation() {
// This might throw
const result = await fetchData();
return result;
}
// DANGER: No error handling
riskyOperation();
If fetchData() rejects, the error bubbles up... to nowhere. No try/catch, no .catch(), no handler. Before Node.js 15, this logged a warning. Now? Your server dies.
The Detection Problem
The trickiest part is that some rejections escape your code entirely:
app.get('/users', async (req, res) => {
const users = await getUsers(); // If this throws...
res.json(users);
});
In Express 4.x (still widely used), this doesn't automatically send an error response. The request hangs, eventually timing out. Express 5 added async error handling, but many projects haven't migrated.
The Comprehensive Solution
1. Global handlers (safety net, not primary strategy):
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Log to monitoring service
Sentry.captureException(reason);
// Optionally restart gracefully
});
2. Framework-aware error handling:
// Express wrapper for async routes
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/users', asyncHandler(async (req, res) => {
const users = await getUsers();
res.json(users);
}));
3. The try/catch discipline:
Every async function that's a "boundary" (API route, event handler, cron job) needs explicit error handling:
async function cronJob() {
try {
await performScheduledTask();
} catch (error) {
await notifyOnCall(error);
// Don't rethrow—this is the boundary
}
}
The Forgotten .catch() on Fire-and-Forget
// WRONG: Fire and forget without catch
async function saveAndNotify(data) {
await saveToDatabase(data);
sendNotification(data); // Intentionally not awaited
}
// RIGHT: Handle potential errors
async function saveAndNotify(data) {
await saveToDatabase(data);
sendNotification(data).catch(err => {
console.error('Notification failed:', err);
});
}
Memory Leaks in Long-Lived Async Operations
Async code can leak memory in subtle ways that don't appear until your server runs for days.
The Closure Retention Problem
async function processLargeFile(filePath) {
const hugeData = await readEntireFile(filePath); // 500MB
return async function getSlice(start, end) {
return hugeData.slice(start, end);
};
}
The returned function closes over hugeData. As long as that function exists, 500MB stays in memory—even if you only need tiny slices.
The Solution: WeakRef and FinalizationRegistry
async function processLargeFile(filePath) {
let hugeData = await readEntireFile(filePath);
const dataRef = new WeakRef(hugeData);
hugeData = null; // Allow GC if no one else holds it
return async function getSlice(start, end) {
const data = dataRef.deref();
if (!data) {
throw new Error('Data has been garbage collected');
}
return data.slice(start, end);
};
}
The Event Listener Leak
This one is sneaky because it combines async code with event emitters:
class DataProcessor {
constructor() {
this.eventEmitter = new EventEmitter();
}
async processWithUpdates(data) {
return new Promise((resolve, reject) => {
const onProgress = (progress) => {
console.log(`Progress: ${progress}%`);
};
const onComplete = (result) => {
// LEAK: We never remove onProgress!
resolve(result);
};
const onError = (error) => {
reject(error);
};
this.eventEmitter.on('progress', onProgress);
this.eventEmitter.on('complete', onComplete);
this.eventEmitter.on('error', onError);
this.startProcessing(data);
});
}
}
Every call adds new listeners that are never removed. After 1000 calls, you have 3000 zombie listeners.
The Fix: Always Clean Up
async processWithUpdates(data) {
return new Promise((resolve, reject) => {
const cleanup = () => {
this.eventEmitter.off('progress', onProgress);
this.eventEmitter.off('complete', onComplete);
this.eventEmitter.off('error', onError);
};
const onProgress = (progress) => {
console.log(`Progress: ${progress}%`);
};
const onComplete = (result) => {
cleanup();
resolve(result);
};
const onError = (error) => {
cleanup();
reject(error);
};
this.eventEmitter.on('progress', onProgress);
this.eventEmitter.on('complete', onComplete);
this.eventEmitter.on('error', onError);
this.startProcessing(data);
});
}
The AbortController Pattern
Modern JavaScript provides a cleaner way with AbortController:
async function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} finally {
clearTimeout(timeoutId);
}
}
Race Conditions: The Hardest Bugs to Reproduce
Race conditions occur when the behavior depends on the timing of async operations. They're maddening because they work in development and fail in production.
The Classic: Stale State in React
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
async function loadUser() {
const userData = await fetchUser(userId);
setUser(userData); // BUG: What if userId changed?
}
loadUser();
}, [userId]);
return <div>{user?.name}</div>;
}
If userId changes quickly (e.g., user clicks two links rapidly), both fetches start. The first one might finish second, leaving stale data displayed.
The Fix: Abort Previous Requests
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const userData = await fetchUser(userId, {
signal: controller.signal
});
setUser(userData);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Failed to fetch user:', error);
}
}
}
loadUser();
return () => controller.abort();
}, [userId]);
return <div>{user?.name}</div>;
}
The Backend Race: Double Submission
app.post('/orders', async (req, res) => {
const { userId, productId } = req.body;
// Check if user already ordered this product
const existing = await Order.findOne({
userId,
productId,
status: 'pending'
});
if (existing) {
return res.status(400).json({ error: 'Already ordered' });
}
// Create the order
const order = await Order.create({ userId, productId, status: 'pending' });
res.json(order);
});
If a user double-clicks the order button, two requests arrive nearly simultaneously. Both check for existing orders, both find none, both create orders. Now you have duplicates.
The Fix: Optimistic Locking or Unique Constraints
Database solution (preferred):
// In your migration/schema
Order.createIndex({ userId: 1, productId: 1, status: 1 }, { unique: true });
// In your route
app.post('/orders', async (req, res) => {
try {
const order = await Order.create({
userId: req.body.userId,
productId: req.body.productId,
status: 'pending'
});
res.json(order);
} catch (error) {
if (error.code === 11000) { // MongoDB duplicate key
return res.status(400).json({ error: 'Already ordered' });
}
throw error;
}
});
Application-level locking:
const orderLocks = new Map();
app.post('/orders', async (req, res) => {
const lockKey = `${req.body.userId}:${req.body.productId}`;
if (orderLocks.has(lockKey)) {
return res.status(429).json({ error: 'Request in progress' });
}
orderLocks.set(lockKey, true);
try {
const order = await Order.create({
userId: req.body.userId,
productId: req.body.productId
});
res.json(order);
} finally {
orderLocks.delete(lockKey);
}
});
The Distributed Race: Multiple Servers
When you have multiple server instances, in-memory locks don't work. Use Redis or database locks:
import Redis from 'ioredis';
const redis = new Redis();
async function withDistributedLock(key, ttlMs, fn) {
const lockKey = `lock:${key}`;
const lockValue = crypto.randomUUID();
// Try to acquire lock
const acquired = await redis.set(lockKey, lockValue, 'PX', ttlMs, 'NX');
if (!acquired) {
throw new Error('Could not acquire lock');
}
try {
return await fn();
} finally {
// Only release if we still own the lock
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, lockValue);
}
}
The await in the Wrong Place
Misplaced await is syntactically valid but semantically broken.
The Constructor Anti-Pattern
class DatabaseConnection {
constructor() {
// WRONG: Constructor can't be async!
await this.connect();
}
async connect() {
this.connection = await mongodb.connect();
}
}
This is a syntax error, but developers sometimes try this pattern:
class DatabaseConnection {
constructor() {
this.ready = this.connect(); // Starts connection
}
async connect() {
this.connection = await mongodb.connect();
}
async query(sql) {
await this.ready; // Wait for connection
return this.connection.query(sql);
}
}
This works but is awkward. Every method needs await this.ready.
The Factory Pattern Solution
class DatabaseConnection {
static async create() {
const instance = new DatabaseConnection();
await instance.connect();
return instance;
}
async connect() {
this.connection = await mongodb.connect();
}
async query(sql) {
return this.connection.query(sql);
}
}
// Usage
const db = await DatabaseConnection.create();
The Module-Level await Gotcha
Top-level await (available in ES modules) introduces subtle ordering issues:
// module-a.js
export const data = await fetchData();
console.log('Module A loaded');
// module-b.js
import { data } from './module-a.js';
console.log('Module B loaded, data:', data);
Module B waits for Module A's async initialization. This is usually fine, but circular dependencies become deadlocks:
// user.js
import { posts } from './posts.js';
export const currentUser = await fetchCurrentUser();
// posts.js
import { currentUser } from './user.js';
export const posts = await fetchPosts(currentUser.id); // DEADLOCK
Neither module can load because each waits for the other.
The Promise.all vs Promise.allSettled Decision
Choosing wrong here causes either swallowed errors or premature failures.
Promise.all: Fail-Fast
const results = await Promise.all([
fetchUser(1),
fetchUser(2),
fetchUser(3)
]);
If any Promise rejects, Promise.all immediately rejects, and the other Promises' results are discarded. If fetching user 2 fails, you lose user 1 and 3, even if they succeeded.
Promise.allSettled: Complete All
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(2),
fetchUser(3)
]);
const users = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
const errors = results
.filter(r => r.status === 'rejected')
.map(r => r.reason);
Every Promise runs to completion. You get all results and all errors.
When to Use Which
- Promise.all: When you need all results or none (atomic operations)
- Promise.allSettled: When partial success is acceptable (batch operations)
The Hybrid: Promise.all with Individual Error Handling
const results = await Promise.all([
fetchUser(1).catch(err => ({ error: err, id: 1 })),
fetchUser(2).catch(err => ({ error: err, id: 2 })),
fetchUser(3).catch(err => ({ error: err, id: 3 }))
]);
results.forEach(result => {
if (result.error) {
console.log(`Failed to fetch user ${result.id}`);
}
});
The Thenable Trap
Not all .then()-able objects are Promises.
const notAPromise = {
then(resolve) {
resolve('surprise!');
}
};
async function test() {
const result = await notAPromise; // Works!
console.log(result); // 'surprise!'
}
JavaScript's await accepts any "thenable"—an object with a .then() method. This can cause confusion with libraries that return custom thenables.
The Library Gotcha
Some ORMs return query builders that are thenables:
// Knex.js
const query = db('users').where('id', 1);
// This actually executes the query!
const user = await query;
// So does this... twice!
const user1 = await query;
const user2 = await query;
Each await executes the query again. For immutable results, this wastes resources:
// Build once, execute once
const user = await db('users').where('id', 1).first();
Async Generators: The Forgotten Power Tool
Async generators combine iteration with async operations, perfect for processing large datasets:
async function* readLargeFile(path) {
const stream = fs.createReadStream(path, { encoding: 'utf8' });
for await (const chunk of stream) {
yield chunk;
}
}
// Process without loading entire file into memory
for await (const chunk of readLargeFile('huge.txt')) {
await processChunk(chunk);
}
The Async Iterator Protocol
If you're building custom async iterables:
class AsyncQueue {
#items = [];
#waiting = [];
push(item) {
if (this.#waiting.length > 0) {
const resolve = this.#waiting.shift();
resolve({ value: item, done: false });
} else {
this.#items.push(item);
}
}
[Symbol.asyncIterator]() {
return {
next: () => {
if (this.#items.length > 0) {
return Promise.resolve({
value: this.#items.shift(),
done: false
});
}
return new Promise(resolve => {
this.#waiting.push(resolve);
});
}
};
}
}
// Usage
const queue = new AsyncQueue();
// Consumer
(async () => {
for await (const item of queue) {
console.log('Received:', item);
}
})();
// Producer
queue.push('Hello');
queue.push('World');
Debugging Async Code: Practical Techniques
1. Async Stack Traces
Node.js 12+ includes async stack traces by default, but in complex code, they can still be confusing. Use named functions:
// Hard to debug
const result = await somePromise.then(x => x.map(y => y.value));
// Easy to debug
const result = await somePromise.then(function extractValues(items) {
return items.map(function getValue(item) {
return item.value;
});
});
2. The Async Debugging Pattern
async function debuggableOperation(input) {
const startTime = performance.now();
const operationId = crypto.randomUUID().slice(0, 8);
console.log(`[${operationId}] Starting operation with input:`, input);
try {
const step1Result = await step1(input);
console.log(`[${operationId}] Step 1 completed:`, step1Result);
const step2Result = await step2(step1Result);
console.log(`[${operationId}] Step 2 completed:`, step2Result);
const finalResult = await step3(step2Result);
console.log(`[${operationId}] Operation completed in ${performance.now() - startTime}ms`);
return finalResult;
} catch (error) {
console.error(`[${operationId}] Operation failed at ${performance.now() - startTime}ms:`, error);
throw error;
}
}
3. Promise State Inspection
You can't directly inspect a Promise's state, but you can race it:
async function getPromiseState(promise) {
const sentinel = Symbol('pending');
const result = await Promise.race([
promise,
Promise.resolve(sentinel)
]);
if (result === sentinel) {
return 'pending';
}
return 'resolved';
}
4. Async Hooks for Tracing
Node.js's async_hooks module lets you trace async operations:
import async_hooks from 'async_hooks';
import fs from 'fs';
const contexts = new Map();
const hook = async_hooks.createHook({
init(asyncId, type, triggerAsyncId) {
fs.writeSync(1, `${type} created: ${asyncId} (triggered by ${triggerAsyncId})\n`);
contexts.set(asyncId, { type, parent: triggerAsyncId });
},
destroy(asyncId) {
contexts.delete(asyncId);
}
});
hook.enable();
Testing Async Code: Common Mistakes
The Forgotten await in Tests
// WRONG: Test passes even if assertion fails!
it('should fetch user', async () => {
fetchUser(1).then(user => {
expect(user.name).toBe('Alice'); // Never awaited
});
});
// RIGHT
it('should fetch user', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
Testing Rejections
// RIGHT: Using Jest's rejects matcher
it('should reject invalid input', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Invalid ID');
});
// Or with try/catch
it('should reject invalid input', async () => {
try {
await fetchUser(-1);
fail('Expected error to be thrown');
} catch (error) {
expect(error.message).toBe('Invalid ID');
}
});
Fake Timers with Async
// Fake timers can break async tests
jest.useFakeTimers();
it('should timeout after 5 seconds', async () => {
const promise = fetchWithTimeout('/slow');
// Advance timers
jest.advanceTimersByTime(5000);
// Must await the microtask queue
await jest.runAllTimersAsync(); // Jest 29+
await expect(promise).rejects.toThrow('Timeout');
});
The Performance Implications You're Missing
Microtask Queue Flooding
Each await creates a microtask. In tight loops, this can delay I/O:
// Floods microtask queue
async function processItems(items) {
for (const item of items) {
await processItem(item); // Creates microtask
}
}
For CPU-intensive work interspersed with I/O, break up the work:
async function processItems(items) {
for (let i = 0; i < items.length; i++) {
await processItem(items[i]);
// Yield to I/O every 100 items
if (i % 100 === 0) {
await new Promise(resolve => setImmediate(resolve));
}
}
}
V8 Optimization and Async Functions
V8 can't always optimize async functions as well as synchronous ones. For hot paths, consider:
// Hot path: synchronous when possible
function getValue(cache, key) {
const cached = cache.get(key);
if (cached !== undefined) {
return cached; // Sync return
}
return fetchValue(key).then(value => {
cache.set(key, value);
return value;
});
}
This returns synchronously for cache hits, avoiding Promise overhead.
Conclusion: The Async Mindset
Mastering async/await isn't about memorizing patterns—it's about developing intuition for how asynchronous operations flow through your code.
Key takeaways:
-
Parallelize by default: Use
Promise.allunless you have a reason not to - Always handle errors: At every async boundary, decide who handles failures
- Clean up resources: Event listeners, timers, and connections don't clean themselves
- Race conditions are everywhere: Design for concurrent access from the start
- Test the unhappy path: Rejections, timeouts, and partial failures need explicit tests
- Measure before optimizing: Async overhead rarely matters; I/O latency usually does
The bugs covered in this guide aren't theoretical—they're drawn from real production incidents. Each one looked correct at first glance. That's what makes async programming hard: the syntax hides the complexity.
But complexity, once understood, becomes manageable. You now have the tools to write async code that works not just in your tests, but in production at 3 AM when you're asleep.
That's the goal. Async code shouldn't be exciting. It should be boring, reliable, and correct—so you can focus on building features instead of debugging timing issues.
Build with intention. Test the edge cases. And always, always handle your rejections.
🔒 Privacy First: This article was originally published on the Pockit Blog.
Stop sending your data to random servers. Use Pockit.tools for secure utilities, or install the Chrome Extension to keep your files 100% private and offline.
Top comments (0)