Back

The Async/Await Pitfalls You're Still Making in 2026: A Complete JavaScript Debugging Guide

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:

  1. Parallelize by default: Use Promise.all unless you have a reason not to
  2. Always handle errors: At every async boundary, decide who handles failures
  3. Clean up resources: Event listeners, timers, and connections don't clean themselves
  4. Race conditions are everywhere: Design for concurrent access from the start
  5. Test the unhappy path: Rejections, timeouts, and partial failures need explicit tests
  6. 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.

JavaScriptAsync/AwaitDebuggingNode.jsPerformanceBest Practices

Explore Related Tools

Try these free developer tools from Pockit