Home / Articles / Common async/await Misconceptions That Cause Production Bugs

This article is published in English.

Common async/await Misconceptions That Cause Production Bugs

Explains nine subtle async/await misunderstandings—from race conditions to unhandled rejections—that silently break real-world JavaScript applications.

3620 words

For a long stretch, many developers assume they have a solid grasp of async/await simply because they use it fluently. Knowing that an async function always returns a promise, knowing to put await in front of a call that hits the network, and knowing try/catch makes handling asynchronous failures more manageable can feel like enough. Compared to callback-heavy code, this style looks tidy, and it reads almost like plain synchronous JavaScript: grab the user, load their account, refresh the UI, and catch whatever goes wrong along the way. The syntax feels so intuitive that it's easy to never stop and examine the mental model behind it.

The real issue is that this surface-level fluency covers the shape of async/await, not the underlying rules of asynchronous ownership. It's common to treat await as if it froze the entire program, to assume that invoking an async function automatically ties its outcome to the caller, and to believe that code written in a linear, top-to-bottom style couldn't possibly race against itself. These assumptions rarely cause visible harm in small demos, since only one operation runs at a time, responses come back quickly, and mocked data resolves in a predictable order. Real production systems are far less forgiving.

The bugs that eventually surface don't look like textbook async mistakes. A function returns before some side effect has actually finished. A stale request clobbers the state set by a newer one. A try/catch block lets a rejection slip past it. A batch job fires off more concurrent work than the system can realistically absorb. Each promise, examined on its own, looks perfectly fine — yet the workflow as a whole behaves nothing like what was originally intended.

It can take a long time to fully internalize the lesson: async/await doesn't eliminate concurrency in JavaScript. All it does is give a single async function a more readable way to pause and pick back up. Everything beyond that still hinges on which promises get returned, which get awaited, which get silently dropped, which get canceled or retried, and which are allowed to mutate shared state along the way.

I Thought await Paused More Than One Function

A common first misconception is straightforward. Whenever await appears, it's tempting to mentally treat it as pausing everything around it. JavaScript never blocks the browser tab or the Node.js process, but it's easy to still assume the surrounding logic will somehow wait its turn.

That's not how it works. await only suspends the specific async function it lives inside. Control returns to the runtime while the awaited promise is still pending, and JavaScript keeps handling whatever else is ready to run. Event listeners can fire, timers can go off, unrelated requests can resolve, and even another call to the very same function can start up in the meantime.

async function loadProfile() {
  console.log("Loading profile");

const profile = await fetchProfile();
  console.log("Profile loaded");
  return profile;
}
console.log("Before");
loadProfile();
console.log("After");

Nothing about the outer code waits just because loadProfile happens to contain an await. Calling the function kicks it off and hands back a promise right away. Unless whoever called it chooses to await or return that promise, execution simply moves forward without pausing.

That might sound like a minor technicality, but its effects go well beyond misordered console logs. A request handler might send its response while an audit-log write is still in flight. A CLI tool might exit while a file write is still pending. A test might report success before assertions buried inside an async callback ever run. The async function is behaving exactly as designed — the real gap is that the caller never linked its own completion to that of the async call.

So the question worth asking isn't whether a function uses await internally. It's whether the promise representing the full operation is actually wired up to the code that needs to know when it's done.

I Called Async Functions Without Deciding Who Owned Their Completion

One mistake that shows up constantly is firing off an async function without awaiting or returning it. Sometimes this is a simple oversight. Other times the work seems unimportant enough to just let run in the background.

The real flaw isn't necessarily that the work runs independently — it's that no one has actually decided who is responsible for its outcome.

Picture an order being saved, followed by a notification being sent. If that notification absolutely must succeed before the whole operation counts as done, the caller needs to await it. If it's genuinely optional, letting it run on its own might be fine, but a rejection from it still needs somewhere to go. Simply discarding the returned promise doesn't magically turn the call into a dependable background job — it just strips the caller of any way to know what happened afterward.

This turns particularly risky in backend code. A function could send back a successful response even though some later asynchronous side effect had failed. The user would walk away thinking the action succeeded, while the system has no durable record that unfinished work still remains. If the process happens to restart at that moment, that pending work simply vanishes.

It helps to treat every unawaited promise as an architectural choice rather than an afterthought. Either the current operation is responsible for the result and must wait on it, or some other layer owns it and needs a proper mechanism for tracking completion, retries, and failures. There's nothing wrong with genuinely intentional background work — but it shouldn't come into existence just because someone forgot to type await.

A promise nobody is watching isn't a background task by design. It's simply work with no owner.

Treating forEach As If It Understood Promises

One of the first async patterns that quietly breaks the usual mental model is pairing forEach with an async callback. The syntax looks perfectly reasonable, which makes it easy to miss why the enclosing function finishes before any of the real work has actually happened.

async function saveUsers(users) {
  users.forEach(async (user) => {
    await saveUser(user);
  });

  console.log("All users saved");
}

A completion message can show up before a single user record has been saved. The reason is that forEach invokes the callback but throws away whatever it returns. An async callback always returns a promise, so each call quietly produces a promise that nobody keeps a reference to. With nothing left to wait on, the outer function resolves immediately, regardless of what its internal calls are still doing.

This isn't really a forEach-specific bug. It comes from assuming that handing an async function to any array method automatically upgrades that method's contract to understand promises. It doesn't. A synchronous iteration helper stays synchronous no matter what kind of function is passed into it, unless that helper was purpose-built to coordinate asynchronous work.

The same trap shows up with map, filter, and reduce. Using map with an async callback returns an array full of pending promises, not an array of final values. filter never waits on an async predicate, so it evaluates the promise objects themselves — which are always truthy — instead of the results they eventually produce. It's possible to build an async version of reduce that behaves correctly, but it tends to be awkward to follow, since the accumulator is itself a promise that has to be carefully unwrapped at each iteration.

Once this becomes clear, writing correct code stops being about memorizing which array method is "allowed" and becomes about picking the execution model the task actually requires. When each step genuinely depends on the one before it finishing, a for...of loop with await inside expresses that intent directly. When the steps are independent and can happen at the same time, mapping them into an array of promises and awaiting them together with Promise.all is often the right move. And when concurrency needs to be capped, neither a bare loop nor an unbounded Promise.all gets the job done.

The syntax has to follow from the operational need, not the other way around. It's tempting to pick whichever pattern looks idiomatic first and hope the desired behavior follows from it, but that order of operations is backwards.

Assuming Promise.all Made Things Faster Without Risk

After discovering that forEach never waits, a natural next step is reaching for Promise.all, which is generally the right tool: map the items into async operations, hand the resulting promises to Promise.all, and wait for the whole set to settle together.

That pattern shines when the individual operations don't depend on one another, the batch size stays reasonable, and a single failure is meant to invalidate the entire result. Problems start when it gets used well outside those boundaries.

Promise.all doesn't throttle anything on its own. Most of the underlying work starts the moment the promises are created, so mapping over a few thousand records can fire off a few thousand database queries or outbound requests nearly simultaneously. Code like this can look fine against a small local dataset and then choke connection pools, hit rate limits, or blow through memory once it meets production-scale input.

Its error handling can also be surprising. When one promise in the group rejects, the rest are not automatically stopped. They keep running, which means they can still be writing records, sending messages, or mutating external state even after the calling code has already jumped into the catch block. Saying "the batch failed" is technically accurate, but it doesn't mean nothing happened as a side effect.

Retrying the whole batch afterward can then redo work that already succeeded on the first pass. At that point the issue isn't promise mechanics anymore — it's idempotency and handling partial completion.

Before defaulting to Promise.all, it helps to ask a different set of questions. How many operations is it actually safe to run at once? Are they truly independent of each other? What should one failure mean for the rest of the batch? Is there any way to stop work that has already begun? If a retry happens, could items that already completed get duplicated? Does the caller genuinely need every single result, or would an honest partial success be acceptable?

Sometimes Promise.all is still the right answer. Sometimes Promise.allSettled fits better. Other times the situation calls for a sequential loop, a concurrency limiter, a queue, or a transaction wrapping the whole operation. Which one is correct depends on the guarantees the operation needs to uphold, not on how fast the code appears at first glance.

Assuming Code That Reads Top-to-Bottom Can't Race

Perhaps the costliest misunderstanding of all is believing that await shields code from race conditions. Within a single function, statements after an await do resume in order, which makes the function feel like one continuous sequence. But across multiple concurrent calls to that same function, several executions can easily overlap.

Picture two requests that each read a balance, compute a new balance, and save it. Both requests await their read, and both await their write. Every line inside each call executes in the order you'd expect. The race condition shows up anyway, because both requests can read the same starting balance before either one has written its update back.

The same shape of bug appears on the frontend. A search fires for an older query, then a second search fires for a newer one. The newer request happens to resolve first and correctly updates the UI. The older request finishes afterward and overwrites that correct state with a stale result. Each call awaited its own fetch exactly as intended — what's missing is a rule for which call still has the authority to update the interface.

This is worth keeping in mind for every await sitting between a state read and the action taken on it. The pause isn't a harmless gap in time. It's a window where other work can run and invalidate the assumptions the function made right before it paused.

Checks performed at the application level don't automatically survive that window. Confirming a username is free and then inserting it offers no protection against two requests doing the same check at nearly the same moment. Verifying that a record is still pending before approving it doesn't guarantee some other process hasn't already changed its state in between.

Real protection usually needs to come from somewhere lower down: a uniqueness constraint at the database level, a conditional update, optimistic locking, an idempotency key, a transaction, or explicit ownership rules enforced in the UI. await only manages the completion of one specific promise. It does nothing to lock shared state or to guarantee that a value read earlier is still true by the time it's acted on.

Expecting try/catch to Catch Work That Was Never Awaited

Another common mistake looks completely safe during code review. An async call sits inside a try/catch block, and it seems reasonable to assume that any rejection will be handled there.

try {
  sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

When sendAnalyticsEvent is an async function that rejects after it has already returned its promise, the surrounding catch block never sees that failure. The synchronous part of the call succeeded the moment it handed back a promise. The rejection happens afterward, outside the stack frame that the try block is watching.

The catch block only works if the promise is awaited from inside it:

try {
  await sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

Stated this plainly, the difference sounds obvious, but the earlier version looks safe precisely because the async call sits visually inside an error handler. The layout suggests a relationship that the code never actually establishes.

The same gap shows up inside callbacks and event handlers. An async callback can reject internally, but if the code invoking it never awaits or inspects the promise it returns, that rejection has nowhere to go. Wrapping the call site in a try/catch doesn't help, because the rejection belongs to a promise that nothing in scope is watching.

The underlying rule is that errors in async code travel with promises, not with indentation. Catching a rejection requires awaiting the promise directly, returning it into a chain that already has a handler, or attaching an explicit rejection callback. Discard the promise, and its error path gets discarded along with it.

This also matters for catch blocks that absorb too much. Turning every failure into a fallback null creates a separate problem: callers can no longer tell a genuine failure apart from a legitimately empty result. Simply catching the error isn't the goal. The code needs to preserve the meaning of that failure for whoever consumes it next.

Mistaking Cancellation for Reversal

When a team first adopts AbortController, it's tempting to assume that cancelling a request means the underlying work has actually stopped. For fetches issued from the browser, aborting does stop the client from continuing to wait, and it often prevents unnecessary follow-up processing on the client itself. That's genuinely useful for things like live search, navigating away from a page, or discarding a read that's no longer relevant.

What it doesn't guarantee is that work already handed off to the server gets undone.

If a write request is aborted after the server has already received it, the backend may go on to finish the database update or the external call regardless. All the client actually knows is that it stopped listening for the response. Retrying that request without some form of idempotency protection risks repeating an operation that already succeeded on the far end.

This distinction exists because client-side cancellation and server-side state live on opposite sides of a network boundary. The client can withdraw its interest in a result, but it has no way to reach across that boundary and undo effects that have already landed.

Cancellation is best treated as a signal about relevance rather than a guarantee about state. It tells the rest of the app that the caller no longer cares about a particular result, so that result shouldn't be allowed to overwrite current state. For read operations, aborting the request is a reasonable optimization. For writes, a separate mechanism is still needed to know whether the operation finished, is still in flight, or can be retried without duplicating its effect.

Cancellation addresses whether a result is still wanted. It does not address whether the underlying action is still consistent.

Writing Async Syntax Before Defining the Workflow

The common thread through all of these mistakes is treating asynchronous design as if it were purely a matter of syntax, deciding whether to reach for await, Promise.all, or an async callback before working out what guarantees the operation actually needs to provide.

The more useful questions come earlier. Does the caller need to wait for this to finish before moving on? Can multiple tasks safely run at the same time? Does their relative order matter? What's the right response when some succeed and others don't? Is it safe to retry this operation? Who is responsible for handling the error? Could a stale result still end up overwriting shared state? And when something times out, does that mean the operation failed, or only that this particular caller gave up waiting?

Once those questions are answered, the actual JavaScript usually falls into place with little difficulty.

A sequence where order genuinely matters can use a loop that awaits each step in turn. Independent tasks with a known, bounded count can run concurrently. Larger batches can be processed with a concurrency limit. Work that doesn't need to block the caller can be handed off to a durable queue. Updates to shared state can be gated behind explicit ownership checks. Database writes can enforce their invariants atomically at the storage layer. Operations where a duplicate would be costly can use idempotency keys so that a retry can't create a second copy of the same effect.

The hard part is never getting the placement of await right. It's working out what "done" actually means at every boundary the operation crosses.

Knowing the Keyword but Not the Contract

Getting async/await wrong for years has less to do with forgetting how the syntax works, and more to do with the fact that it makes asynchronous code look far more sequential, local, and predictable than it actually is.

Seeing an await invites the assumption that everything around it is paused too. Calling an async function without deciding who is responsible for its eventual completion is easy to overlook. Running synchronous array methods with async callbacks treats them as if the two understood each other, which they don't. Reaching for Promise.all without thinking about load or what happens when only some promises succeed is a natural but risky habit. Trusting code that reads top to bottom, even when several calls can genuinely overlap in time, hides races in plain sight. Expecting try/catch to catch rejections from promises that were never awaited, and mistaking a cancelled request for proof that nothing happened on the server, both come from the same blind spot.

Every one of these mistakes traces back to the same gap: the gap between how the code looks and what it actually promises.

Reading asynchronous code well means tracing promises across function boundaries instead of just reading top to bottom. It means checking who creates them, who awaits them, who's responsible for handling rejection, and which piece of state still has authority over the result once everything settles. It means paying close attention to every point where await creates a pause, because that pause is exactly where other work can change the assumptions the function was relying on.

The code can still look sequential on the page. That appearance no longer means the system behaves that way.

This shift turns async JavaScript from a set of keywords into a model built around ownership, timing, and failure. It also explains why code that looked correct for years can still misbehave in production despite passing every test.

Learning how to wait for a promise is the easy part.

Understanding what the rest of the program is doing while that wait happens takes considerably longer.