DEV Community

HK Lee
HK Lee

Posted on • Originally published at pockit.tools

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)))
);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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);
}));
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
]);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
});
Enter fullscreen mode Exit fullscreen mode

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!'
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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;
  });
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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');
  }
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  });
}
Enter fullscreen mode Exit fullscreen mode

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.


🔒 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)