Advertisement

Asynchronous programming trips people up because the code looks sequential but does not execute sequentially. You write one line after another, yet some lines "pause" and let other work happen in between. Modern async/await syntax makes this far more readable than older callback styles, but it still needs the right mental model to stop being confusing.

The core idea is about waiting without blocking.

The problem it solves

Some operations are slow because they wait on something external — a network request, a disk read, a timer. If your program simply stopped and waited for each of these, it would freeze, unable to do anything else useful. Asynchronous code lets the program start a slow operation, carry on with other work, and come back when the result is ready.

This matters enormously for anything with a user interface or that handles many requests, where freezing while waiting is unacceptable.

Advertisement

What await actually does

The await keyword marks a point where you want the result of an asynchronous operation before continuing that particular flow. Crucially, it does not freeze the whole program — it pauses that function while letting other work proceed, then resumes when the awaited result arrives. Reading it as "pause here until this is ready, but let everything else keep going" captures the behaviour well.

This is why async code reads almost like ordinary sequential code, while behaving very differently underneath.

The common traps

Two mistakes recur. First, forgetting that awaited operations can fail, so async code needs error handling just like anything else. Second, awaiting things one at a time when they could run at once — if several independent operations can proceed in parallel, starting them together and awaiting the group is far faster than a serial chain.

Master the "let other work continue" model, remember failures happen, and parallelise the independent parts. With those, async stops being a source of mystery bugs and becomes a straightforward tool.

Advertisement

Reads top to bottom, but does not run top to bottom

The specific confusion async/await produces, more than either callbacks or raw promises, comes from how convincingly synchronous it looks: a function body with several `await` statements in sequence reads exactly like a script that runs one line, then the next, in the order written on the page. What is actually happening underneath is that each `await` yields control back to the event loop and the rest of the function's body is registered as a continuation to resume once that specific promise settles — the illusion of sequential execution is real in the sense that the results genuinely do become available in that order, but it is not real in the sense that the JavaScript engine is not sitting there, blocked, waiting between lines the way it would be in a genuinely synchronous language. Other code on the same thread runs during every single `await`, which is invisible from reading the function alone but is exactly what makes this model useful in the first place.

Where execution actually goes during an await

When an `async` function hits an `await`, execution of that function pauses at exactly that point and control returns immediately to whatever called it, which continues running its own next line of code without waiting. If that caller was itself the top-level script or another async function that is not itself being awaited by anyone yet, control eventually returns to the event loop, which is then free to process any other pending work — another request, another timer, another I/O completion — until the awaited promise settles and the paused function's continuation is placed back on the microtask queue to resume exactly where it left off. This is the mechanism, made concrete, behind the more abstract claim that 'async/await does not block the thread' — it is not a special property of the syntax, it is exactly what a promise-based `.then()` chain does, wearing syntax that hides the chaining.

Advertisement

The most common footgun: 'await' inside a loop that should have run in parallel

A `for` loop with `await` inside it — `for (const url of urls) { results.push(await fetch(url)); }` — is a strong candidate for the single most common async/await performance mistake, because it reads as ordinary, correct-looking code and produces correct output, just slower than necessary: each fetch waits for the previous one to fully complete before starting the next, turning what could have been one round trip's worth of wall-clock time (if the requests are actually independent) into the sum of every individual request's time, one after another. Recognizing this requires actively asking, for every `await` written inside a loop, whether this iteration genuinely depends on the previous one's result — if not, `Promise.all(urls.map(url => fetch(url)))` starts every request at once and is very often a straightforward, meaningful performance win sitting in code that otherwise looks completely fine.

Top-level await and why it took years to standardize

For most of async/await's history, the keyword could only appear inside a function explicitly marked `async` — using it at a script's top level, outside any function, was a syntax error, which meant any genuinely top-level asynchronous setup (waiting for a database connection before the rest of a module could run, for instance) had to be wrapped in an immediately-invoked async function purely to gain access to the keyword. Top-level await, standardized more recently in ECMAScript modules, removes that requirement, letting a module pause its own evaluation at the top level while an import or setup step resolves — a small ergonomic change on its face, but one that took real specification work, because letting a module's own loading pause on an asynchronous operation has non-trivial implications for how and when the modules that depend on it are allowed to continue their own loading.

Debugging async stack traces: the gap async/await mostly closed

One of the more concrete practical improvements async/await brought over raw callback and promise-chain code is stack trace quality: an error thrown inside an `await`-based function generally preserves a stack trace pointing back through the logical call chain a developer actually wrote, whereas an error surfacing from deep inside a callback chain often shows a stack trace dominated by internal event-loop and I/O-scheduling machinery, with little or no trace of the actual application-level call sequence that led there. This is not an accident of syntax alone; modern JavaScript engines specifically optimized stack trace capture for the async/await pattern precisely because it became the dominant style, which is a case where a language feature's popularity fed back into engine-level investment that made the feature itself meaningfully more debuggable over time.

Why "async" always returns a promise, even from a plain return statement

Marking a function `async` changes its return semantics unconditionally, even if the function body never actually awaits anything: an `async` function that simply does `return 5` does not return the number 5 directly to its caller, it returns a promise that resolves to 5, and calling code has to `await` it or attach a `.then()` to actually get the value out. This is easy to miss because such a function can look, at a glance, exactly like an ordinary synchronous one, and forgetting that its return value is wrapped is a small but genuinely common source of bugs — comparing the return value directly against 5 rather than against the promise it actually is, for instance, silently fails rather than throwing an obvious error.

Async functions and the arguments they cannot un-await

Passing an unresolved promise into a function that expects a plain value, rather than awaiting it first, is a mistake the language mostly does not protect against by itself, because a promise is a perfectly ordinary object as far as most JavaScript code is concerned — it can be stored, passed around, and logged without complaint, and only actually using it as though it already held its resolved value reveals the mistake, usually much later and further from the original call site than where the missing `await` actually belongs. Reading a function signature carefully to notice which of its parameters are documented or typed as promises, rather than assuming a value is already resolved just because it is being passed around like one, is the discipline that catches this class of bug at the point it is introduced rather than several layers downstream where it finally causes something visibly wrong.

Sequential dependency versus sequential code: two different reasons to await in order

Code that awaits several operations one after another is not automatically a performance mistake — it is entirely correct and necessary when each step genuinely depends on the result of the one before it, fetching a user record and then fetching that specific user's orders using the ID the first call returned. The mistake discussed earlier in this cluster of articles is specifically awaiting independent operations sequentially, ones that do not actually need each other's results at all; distinguishing the two cases is a matter of asking, for each `await`, whether the next line genuinely needs a value the current one produced, or whether it merely happens to be written afterward in the source and could just as correctly have started at the same time.

What "async" does not do: it never makes anything faster on its own

A common misreading of async/await treats it as a performance feature in its own right — marking a function `async` does nothing whatsoever to make the work inside it complete any faster; it changes how the waiting is handled, not how long the underlying operation actually takes. Wrapping a slow database query in an `async` function does not speed the query up by a single millisecond; what it changes is that the thread is free to do other useful work while that same slow query is still pending, rather than sitting idle. Confusing 'does not block other work' with 'runs faster' leads to real disappointment when a single, isolated `await`-based operation is timed on its own and shows no improvement at all — the benefit of this model shows up in overall throughput under concurrent load, not in the latency of any one individual operation measured in isolation.

Why linters specifically check for missing await

Given how quietly a missing `await` fails, most modern JavaScript and TypeScript linting setups include a rule specifically dedicated to flagging a floating, unhandled promise — a call to an async function whose result is neither awaited, returned, nor explicitly assigned and disposed of — precisely because this class of bug produces no runtime error at the point it is introduced and is otherwise easy for even an experienced developer to miss during ordinary code review.

Why "await" on a non-promise value is still valid, and rarely useful

Awaiting a plain value that is not a promise at all — `await 5` — is syntactically legal and simply resolves immediately to that same value on the next microtask tick, which is a harmless but easy-to-overlook detail: it means a function can safely await something that might or might not actually be a promise without needing to check first, though relying on this behavior as an actual pattern rather than an occasional convenience tends to make code less clear about what is genuinely asynchronous and what is not.