Skip to main content
Common Async-Await Pitfalls

7 Async-Await Traps That Crash Your App and How to Dodge Them

Async-await in JavaScript and TypeScript seems straightforward, but developers repeatedly fall into traps that cause silent failures, memory leaks, and production outages. This guide explores seven critical pitfalls—from unhandled rejections and sequential-over-parallel execution to forgotten cancelation and deadlock patterns—and provides concrete strategies to avoid them. Drawing on real-world scenarios, we examine how to structure async flows with proper error boundaries, concurrency control, and resource cleanup. Whether you are building Node.js backends, React frontends, or cloud functions, mastering these patterns will save you from debugging nightmares. The article includes a comparison of error handling approaches, a step-by-step walkthrough for refactoring legacy promise chains, and a decision checklist for choosing between async patterns. By the end, you will have a mental model for reasoning about asynchronous control flow and a set of battle-tested practices to keep your applications resilient.

图片

Why Your Async-Await Code Breaks in Production

Async-await transformed JavaScript from callback hell into something that reads like synchronous code. Yet the same convenience often lures developers into ignoring the asynchronous reality underneath. In production, where network latency spikes, servers restart, and concurrent requests pile up, seemingly innocent async functions can cascade into crashes. Understanding the gap between how async-await looks and how it actually executes is the first step to writing robust code.

Consider a typical Express.js handler that fetches user data, then sends a response. If the fetch fails, the error might bubble up to an unhandled rejection listener, but the response never gets sent, leaving the client hanging. Multiply that by hundreds of concurrent requests, and your server memory grows unbounded. This is not a theoretical risk—practitioners often report debugging such issues after a deployment goes live. The core problem is that async-await masks the underlying promise lifecycle, making it easy to forget error propagation, early returns, and resource cleanup.

In this guide, we will dissect seven specific traps that consistently cause production incidents. For each trap, we will explain why it happens, show a concrete example, and offer a pattern to avoid it. The goal is not just to list pitfalls but to equip you with a decision framework for designing async code that is resilient by default.

Trap #1: Unhandled Rejections and Forgotten Error Boundaries

The most common async-await trap is assuming that try-catch around an await covers all failure modes. In reality, many rejection sources bypass your catch block. For example, if you await a promise that rejects inside a callback passed to an array method like map, the rejection might remain unhandled unless you explicitly wrap it. Similarly, event emitters or timers that reject outside the lexical scope of a try block can escape your handler.

The Silent Crash Scenario

Imagine a function that processes a batch of API calls using Promise.all with await. If one call fails, the entire batch rejects, but if you did not wrap the await in a try-catch, the error becomes an unhandled rejection. In Node.js, unhandled rejections used to be warnings; since Node 15, they terminate the process. This means a single flaky upstream service can take down your entire application. One team I read about lost an entire microservice during a flash sale because a third-party rate limit error was not caught inside a map callback, causing the process to exit.

How to Fix: Always Add a Top-Level Catch

Adopt a pattern where every async function that can be invoked externally (route handlers, event listeners, scheduled jobs) has a try-catch that logs, reports to an error tracker, and sends a fallback response. Additionally, register a process-level handler for unhandled rejections to log and gracefully degrade, not crash. In Node.js, use process.on('unhandledRejection', ...) and consider using libraries like express-async-errors to automatically catch async errors in Express routes. For promise chains inside loops, use Promise.allSettled when you want partial results, or wrap each iteration in its own try-catch.

Trap #2: Sequential Execution When You Meant Parallel

A subtle but performance-critical trap is writing await inside a loop, which forces each iteration to wait for the previous one to complete. This turns independent async operations into a sequential chain, multiplying total latency by the number of iterations. In frontend applications, this can make the UI freeze; in backend services, it reduces throughput drastically.

The Accidental Waterfall

Suppose you need to fetch details for a list of user IDs from an API. A naive implementation might look like for (const id of ids) { const data = await fetchUser(id); results.push(data); }. This runs fetches one at a time, so 100 users would take 100 times the latency of a single request. If each request takes 200ms, the total is 20 seconds—unacceptable for most real-time applications. The developer often does not notice in local testing because the list is small or the API is fast, but production load reveals the bottleneck.

How to Fix: Use Promise.all or Promise.allSettled

When operations are independent, launch all promises concurrently and await them together. const results = await Promise.all(ids.map(id => fetchUser(id))); reduces total time to the slowest single request. However, be cautious: if the list is huge (thousands), launching all at once might overwhelm the event loop or the downstream service. In that case, use a concurrency limiter like p-limit or a batch pattern that processes chunks in parallel with a fixed concurrency (e.g., 10 at a time). Measure and tune the concurrency level based on the service's rate limits and your server's resource profile.

Trap #3: Forgotten Cancelation and Stale Responses

When a user navigates away from a page or a client disconnects, in-flight async operations continue to run, consuming resources and potentially updating state with stale data. In React, this leads to the infamous "Can't perform a React state update on an unmounted component" warning, but the deeper issue is wasted CPU, memory, and bandwidth. In Node.js, abandoned requests can pile up, causing memory leaks and degraded performance.

The Zombie Fetch

A common scenario: a user types a search query, triggering an API call. Before the response arrives, the user types another character, triggering a new call. The first response, when it arrives, updates the UI with outdated results. This is not just a UX problem—the first request's promise still holds references to closures, preventing garbage collection. Over time, the browser tab becomes sluggish. In serverless environments, each abandoned invocation still counts toward execution duration and cost.

How to Fix: Implement Abort Controllers and Race Patterns

Use the AbortController API (available in browsers and Node 15+) to signal cancelation to fetch requests. In React effects, clean up by calling abort() in the cleanup function. For custom async flows, you can design a cancelation token pattern. Another approach is to use a race pattern: const result = await Promise.race([fetch(url), timeout(5000)]); to enforce deadlines. For debounced search, use a combination of debounce and a cancelable promise wrapper that rejects when a new request starts. Always ensure that your async functions handle abort signals gracefully, ignoring the result if the operation was canceled.

Trap #4: Deadlocks and Starvation with Semaphores

When you mix async-await with traditional concurrency primitives like mutexes or semaphores, you can create deadlocks that freeze your application. JavaScript is single-threaded, but async operations can interleave in unexpected ways, especially when using asynchronous locks from libraries. A common mistake is to acquire a lock inside a function that is called recursively or from multiple paths, causing the same code to wait for itself.

The Self-Defeating Lock

Imagine a rate limiter that uses a semaphore to allow only 5 concurrent requests to an external API. If the function that acquires the lock also awaits another operation that internally tries to acquire the same lock, you have a deadlock. For example, a cache refresh function that calls acquire() and then awaits a fetch that triggers another cache refresh—the second call waits forever because the first call holds the lock but is blocked on the second. This pattern is especially common in recursive database queries or queue processing loops.

How to Fix: Reentrant Locks and Careful Design

Use a reentrant lock (one that allows the same context to re-acquire it) or refactor to avoid nested lock attempts. Better yet, design your async flows so that locks are held for the shortest possible duration and never await inside a critical section unless absolutely necessary. Consider using a queue-based approach instead of a lock: enqueue tasks and process them sequentially, which avoids the need for explicit mutual exclusion. If you must use semaphores, document the lock acquisition order and use timeouts to detect potential deadlocks. A simple rule: never await a promise that might require the lock you are currently holding.

Trap #5: Ignoring Error Propagation in Promise Combinators

When using Promise.all, Promise.race, or Promise.any, developers often forget that a single rejection can cause the entire combinator to reject, swallowing other results. This is especially dangerous when you have a mix of critical and non-critical operations. For example, if you fetch user data and analytics tracking in parallel, a failure in analytics should not cause the entire page load to fail.

The All-or-Nothing Trap

A common pattern: const [user, analytics] = await Promise.all([fetchUser(), trackAnalytics()]);. If trackAnalytics fails due to a network glitch, the entire Promise.all rejects, and user is never used. The user sees an error page even though their data was ready. In production, this can cause widespread errors from a minor tracking failure. I have seen deployments rolled back because a flaky analytics endpoint caused all requests to fail.

How to Fix: Use Promise.allSettled or Segregated Error Handling

For mixed-criticality tasks, use Promise.allSettled to get the outcome of each promise, then filter for fulfilled ones. Alternatively, wrap each promise with a catch that returns a default value: const analytics = trackAnalytics().catch(() => null);. This way, a failure in one branch does not propagate to the whole. When using Promise.race, remember that the losing promises continue executing—they are not canceled. If you need to cancel them, use abort controllers as discussed in Trap #3. Always decide upfront which failures are critical and which are safe to ignore, and code accordingly.

Trap #6: Async Context Loss and Incorrect This Binding

Async functions change the way this works, especially when used as methods in classes or with event listeners. When you extract an async method from its object, the this context can become undefined or the global object, leading to runtime errors or unexpected behavior. This is a common source of bugs in Node.js servers and frontend components that use class-based handlers.

The Lost Context Bug

Consider a class that has a method async handleRequest(req, res) that uses this.db to query a database. If you pass this method as a route handler without binding, this inside the method will be undefined (in strict mode) or the global object. The error often manifests as Cannot read properties of undefined (reading 'db'). This is especially tricky because the bug only appears when the method is invoked as a callback, not when called directly. Many developers add .bind(this) or use arrow functions, but arrow functions cannot be async methods—they lose the ability to use super or be used as constructors.

How to Fix: Bind in Constructor or Use Arrow Class Properties

In class-based components, bind your async methods in the constructor: this.handleRequest = this.handleRequest.bind(this);. Alternatively, use the class property arrow function syntax: handleRequest = async (req, res) => { ... }. In React functional components, you rarely need this, but when using hooks, be careful with closures: stale closures can capture old state. Use useCallback with proper dependencies, or use refs to hold mutable values. In Node.js, prefer plain functions or modules over classes for route handlers to avoid context issues altogether.

Trap #7: Resource Leaks from Unclosed Streams and Handles

Async-await often works with streams, file handles, database connections, or HTTP sockets. If an async function fails after opening a resource but before closing it, that resource can leak. In server applications, this leads to file descriptor exhaustion, database connection pool depletion, or socket timeouts. The leak is silent and cumulative, often causing crashes hours after deployment.

The Orphaned Connection

A typical pattern: open a database transaction, perform several async queries, then commit or rollback. If one of the queries throws an error caught by a try-catch that logs but does not rollback, the transaction remains open. Over time, the database server may accumulate idle transactions, blocking other operations and eventually hitting connection limits. Similarly, in file processing, if you open a read stream and an error occurs mid-way, the stream may never be closed, holding a file descriptor until the garbage collector runs—which may be too late.

How to Fix: Use Async Disposers or Try-Finally

Always use try-finally blocks to guarantee cleanup, even if you catch errors inside. For resources that implement the async disposable pattern (e.g., Symbol.asyncDispose in future JavaScript), use await using when available. For now, a common pattern is to wrap resource acquisition in a helper function that ensures cleanup: const result = await withTransaction(async (tx) => { ... }). Libraries like p-lock and disposer provide utilities. In Node.js, remember to call stream.destroy() on error. For HTTP requests, use libraries that automatically abort on error, or manually abort the request in your catch block. A good practice is to set a timeout for any resource acquisition and release it if the timeout fires.

Decision Checklist and Mini-FAQ

To help you avoid these traps in your daily work, here is a simple decision checklist you can run through when writing any async function. Ask yourself these questions:

  • Is every await inside a try-catch that covers all exit paths? If not, add a catch that logs and handles the failure gracefully.
  • Are independent operations running sequentially? If yes, consider using Promise.all with appropriate concurrency control.
  • Can the operation be canceled if the caller loses interest? If yes, implement abort signals or race patterns.
  • Is there any chance of a deadlock due to nested lock acquisition? If yes, redesign to avoid holding locks while awaiting.
  • Are mixed-criticality promises combined in Promise.all? If yes, use Promise.allSettled or individual catch handlers.
  • Is this used inside an async method passed as a callback? If yes, ensure the method is bound or use arrow functions.
  • Are resources (streams, connections, files) always closed in a finally block? If not, add explicit cleanup.

Frequently Asked Questions

Q: Should I use Promise.all or Promise.allSettled by default? A: Use Promise.allSettled when you need results from all promises even if some fail, or when failures are non-critical. Use Promise.all when any failure should abort the entire operation (e.g., a batch of database writes that must be atomic).

Q: How do I handle cancelation in older Node versions? A: Use a custom cancelation token pattern: create an object with a cancel method that sets a flag, and check that flag inside your async function at safe points. This is less clean than AbortController but works in any environment.

Q: Is it safe to use async-await in performance-critical loops? A: Avoid await inside tight loops; instead, batch operations or use streams. For CPU-intensive tasks, consider offloading to worker threads or using setImmediate to yield to the event loop.

Synthesis and Next Actions

Async-await is a powerful abstraction, but its simplicity can mask complexity. The seven traps we covered—unhandled rejections, accidental sequential execution, forgotten cancelation, deadlocks, error propagation in combinators, context loss, and resource leaks—are not exotic edge cases; they appear regularly in production systems. The good news is that each trap has a straightforward mitigation strategy that you can adopt incrementally.

Start by auditing your codebase for the most critical patterns: unhandled rejections (check for missing catch blocks in route handlers and event emitters) and resource leaks (look for streams or connections opened without a finally block). Next, refactor any loops that use await to use parallel combinators with concurrency limits. Then, implement abort controllers in any user-facing features where navigation or cancelation is possible. Over time, build a team style guide that codifies these practices.

Remember that async programming is about managing time and resources. Every promise represents an outstanding operation that consumes memory and potentially holds a socket or file handle. Treat each await as a point of potential failure and plan for it. By internalizing these patterns, you will not only prevent crashes but also write code that is easier to reason about and modify.

About the Author

This article was prepared by the editorial team for this publication. We focus on practical explanations and update articles when major practices change.

Last reviewed: May 2026

Share this article:

Comments (0)

No comments yet. Be the first to comment!