async/await in JavaScript - how a function can wait without blocking the thread

async/await looks like magic: you write await fetch(...), get the result on the next line, and the thread doesn't block for a single moment. Underneath there's no magic - just an old pattern of generator + Promise + auto-runner, hidden under the new keywords async and await.
You write this:
async function loadUser() {
const response = await fetch("/api/user/1");
const user = await response.json();
console.log(user.name);
}The function pauses on await, waits a few hundred milliseconds for the network, gets the response, moves on. Meanwhile the thread handles rendering, clicks, scroll. Everything smooth.
It sounds like magic. JavaScript is single-threaded - if a function is waiting, the thread is busy. A normal function, once you start it, runs top to bottom and can’t stop in the middle. Yet here it pauses, frees the thread, and comes back to itself later.
Underneath it’s not magic, just three old building blocks hidden under the new keywords async and await: a Promise, a generator that knows how to pause, and a mechanism that joins the two. This post is about what that mechanism is, why it needed generators, and what the engine actually does when it sees await.
If you’re not sure exactly what a Promise is, start with the Promises post - I assume you know what pending, fulfilled, rejected, and the microtask queue mean. The second block is the generator - there’s a separate post on generators with an interactive demo; here I unpack them only as much as needed to assemble the auto-runner.
#What await actually does
In one sentence:
awaitin front of a Promise pauses execution of this function until the Promise settles, and then substitutes its value in place of the expression.
(The operand of await doesn’t have to be a Promise - if it isn’t, the engine still wraps it in Promise.resolve(x). So await 42 also yields the thread to a microtask, even though there’s nothing to wait for. A small trap worth remembering.)
So these two snippets are equivalent:
const response = await fetch("/api/user/1");
console.log(response.status);
fetch("/api/user/1").then((response) => {
console.log(response.status);
});In both cases the continuation (console.log) only fires once the fetch Promise is ready. But visually the first one is synchronous - line by line. The second - with a callback, indentation, a new function.
The question is: how does the first version look synchronous and act asynchronous?
#What hides under async/await: Promise, generator, auto-runner
Mental model:
- Promise - a placeholder object for a value that doesn’t exist yet. Introduced in ES2015, covered in the Promises post.
- Generator - a function that knows how to pause its own execution mid-body and come back to it later. Also ES2015, much less popular - unpacked in the generators post.
- Auto-runner - a mechanism that takes a generator, calls it step by step, and wherever the generator returned a Promise, fills in the value once the Promise is ready.
The first two blocks - Promise and generator - landed in ES2015. The auto-runner never entered the language, so people wrote it by hand or used a library - the most popular one was called co. It wasn’t until ES2017 that the spec took these three blocks and hid them behind a single keyword async.
#What we take from generators into async/await
The rest - iterator protocol and lazy evaluation - is in the generators post. Here we only need two things.
First - yield as a pause. A function marked function* stops its execution at every yield and hands control back to the outside. Each subsequent g.next() resumes it from exactly the same spot - with local variables and code position preserved.
Second - g.next(value) as input. The argument passed into next() lands at the spot where the generator is paused, as the result of the yield expression. Which means you can talk to a generator both ways: the generator hands out a Promise via yield and says “I’m waiting for the result”, someone outside waits for the Promise to settle, and feeds the value back in via g.next(value). The generator gets it on the right side of yield and continues, as if there had been no pause.
#Generator + Promise = the hand-rolled async/await of 2015
This is how people wrote async code between 2015 and 2017, before the language got async/await:
function* loadUser() {
const data = yield fetch("/api/user/1");
const user = yield data.json();
return user.name;
}
function run(gen) {
const it = gen();
function step(value) {
const result = it.next(value);
if (result.done) return Promise.resolve(result.value);
return Promise.resolve(result.value).then(step);
}
return step();
}
run(loadUser).then((name) => console.log(name));You read this and you see two things. First - loadUser looks synchronous, even though it does two requests inside. Second - all the “magic” lives in run. The runner runs a loop: calls next(), gets the Promise from yield, waits for it to settle, feeds the value back via next(value), keeps going. When the generator finishes (done: true), the runner returns the final value wrapped in a Promise.
This is the simplified version - the real co also has an error path: when the yielded Promise gets rejected, the runner calls it.throw(err) instead of it.next(val), which injects the exception into the generator at the spot after the yield. That’s why try/catch inside the generator catches the error the same way try/catch around await does.
A reader of this code from 2015 saw that the pattern was so universal it would be a shame not to bake it into the language. Brian Terlson submitted a proposal to TC39 - he took this pattern and hid it behind two words: async instead of function*, await instead of yield. Plus an auto-runner built into the engine.
#async/await is the same pattern, just under new keywords
A 1:1 mapping:
| Generator + runner pattern | async/await |
|---|---|
function* foo() | async function foo() |
yield somePromise | await somePromise |
Hand-rolled runner (co, own run) | Built into the engine |
| Result is a Promise | Result is a Promise |
Which means that conceptually every async function is a generator with an auto-runner. The ECMA-262 spec has its own internal steps for this (AsyncFunctionStart, Await, Resume), and modern engines have optimized it - but the mental model is exactly that: the engine sees async, treats the function as something that can pause, and every await is a pause point where the engine itself asks for the Promise and itself substitutes the value.
Two things follow:
- An
asyncfunction always returns a Promise. Because the runner wraps the result in a Promise (just like ourrundoes). Evenasync function foo() { return 42 }returnsPromise<42>, not42. awaitworks inside anasyncfunction - or at the top level of an ESM module (since ES2022, where the whole module behaves like the body of an async function). Becauseawaitis the fact that a function can be paused - and onlyasync/generator functions (and the ESM module) can do that.
#What the engine really does at const response = await fetch(...)
Let’s drop one level lower. You have:
async function loadUser() {
const response = await fetch("/api/user/1");
console.log(response.status);
}
loadUser();
console.log("after the call");Step by step:
loadUser()starts synchronously. You enter the function and execute everything up toawait.fetch("/api/user/1")starts the request - the browser sends an HTTP query.fetchimmediately returns a Promise inpendingstate (becausefetchis a two-pronged facade - it kicks off work in the browser and immediately hands JS an object representing a value that doesn’t exist yet).- The engine sees
await. It does three things:- records where in the function we are (local variables, code position - exactly like a generator after
yield), - registers the continuation (everything after
await) as a callback to.thenon this Promise, - returns from the function - hands the thread back to the call stack.
- records where in the function we are (local variables, code position - exactly like a generator after
console.log("after the call")runs - notice thatloadUser()already left the call stack, even though “inside” it we haven’t finished. The thread is free for other things.- Some time later the browser finishes the request. The
fetchPromise transitions tofulfilledwith the response. The registered continuation lands in the microtask queue. - The event loop picks up the microtask. We jump back into
loadUserat the spot afterawait, substitute the response asresponse, and keep going.console.log(response.status)prints.
Click Next → to step through this flow, or hit Run and watch how loadUser disappears from the stack, the continuation sits in the microtask queue, and after the browser’s response it comes back as the second segment of the same function:
The key: await is not blocking the thread. It’s leaving the function + registering a continuation as a microtask. The function “returns” to itself only when the event loop calls it back. From the code’s perspective it looks like a pause - from the thread’s perspective it’s two separate execution segments, glued together by the engine.
That’s why you don’t block scrolling, animations, clicks - the thread between one segment of loadUser and the next is free for all other tasks.
#Takeaway
Three things to remember:
async/awaitis not a new async mechanism. It’s the generator + Promise + auto-runner pattern baked into the language - the one people wrote by hand between 2015 and 2017 with thecolibrary.awaitdoesn’t block the thread. It exits the function, registers the continuation as a microtask, hands the thread back to the event loop. It returns only once the Promise is ready.- For independent requests, reach for
Promise.allinstead ofawaitin a loop. A sequence turns 200 ms into 2 seconds. UsePromise.allSettledif a single failure shouldn’t wipe the rest, and add a concurrency limit if there are hundreds of requests.
The rest of the syntax - try/catch around await, Promise.all instead of await in a loop, top-level await - are variations on the same decision: trade the hand-rolled generator + runner for two keywords.
Comments
Loading comments…