You write await a dozen times before lunch. Fetch a row, await it. Call a service, await that. It works, you move on, and you never have to think about what the word is doing. Then one day someone asks you to explain it. Maybe it's an interviewer."But what does await actually do?" And you open your mouth and what comes out is "it, uh, waits for the promise." Which is true, and also explains nothing.
We can build async/awit mechanism from scratch using generators as a learning exercise. It requires a pause button wired to a small loop that waits on a promise and then presses play again. You already know one half of that machinery if you read the previous post in this series. The other half is a trick generators have that we glossed over. Put the two together and you can build a working version of async/await yourself, by hand, and watch it behave exactly like the real thing.
Let's do that.
The shape of the problem
Strip await down to what it has to accomplish and you get two requirements:
First, a function has to be able to stop in the middle. Right at the await, freeze everything, the local variables, the spot in the loop, all of it, and hand control back to whoever called it. Normal functions can't do this. They run start to finish and that's the deal.
Second, something on the outside has to wait for the promise to settle and then nudge the frozen function back to life, handing it the resolved value as if the await expression had simply evaluated to it.
That's the whole job. A function that pauses, and a driver that resumes it when a promise is ready. Hold that picture, because the rest of this is just filling in those two pieces with things JavaScript already gives you.
The half you've seen: pausing
A generator function, the function* kind, can pause itself with yield and resume later from the exact same spot. We leaned on that hard in the CSV piece to pull rows through a pipeline one at a time. A line came in, got yielded, and the generator sat frozen until someone asked for the next value.
So pausing is solved. A generator pauses at every yield. If we squint, yield and await start to look like the same gesture: stop here, give something to the outside, wait.
But there's a gap. With the CSV pipeline, values only flowed one way. The generator yielded lines outward and the consumer took them. For await to work, the flow has to go both ways. The function yields a promise outward, and then the resolved value has to come back in and become the result of the expression. const user = await getUser() means the generator needs to receive user at the spot where it paused.
Generators can do this. We just never used it in the CSV piece, because we didn't need it there.
The half you probably haven't: talking back
Here is the trick. When you call .next() on a generator, you can pass it an argument, and that argument becomes the value the paused yield expression evaluates to.
The yield doesn't only push a value out. It also waits to receive one back, and whatever you hand to the next
.next(value)call is what it gets.
A tiny demo makes it concrete:
function* echo() {
const first = yield 'pause-1';
console.log('received:', first);
const second = yield 'pause-2';
console.log('received:', second);
return 'done';
}
const g = echo();
console.log(g.next().value); // pause-1 (runs up to the first yield)
console.log(g.next('A').value); // received: A, then pause-2
console.log(g.next('B').value); // received: B, then done
Look at what happened. The first .next() runs the generator until it hits yield 'pause-1' and stops. The value 'pause-1' comes out. The generator is now frozen on that line.
When we call .next('A'), the 'A' gets injected as the result of that first yield, so first becomes 'A', the log fires, and the generator runs on to the second yield. Two way communication. The generator speaks, and it also listens.
Now line the two halves up. yield pauses and emits a value. .next(value)
resumes and injects a value. If the thing a generator yields is a promise, an outside driver could wait for that promise, take the result, and pass it straight back in through .next(). The generator would never know it had paused at all. From inside, it would look exactly like the value had been sitting there waiting.
That driver is the only piece we're missing.
Building the driver
Here's the runner. This is the heart of the whole post, and it's shorter than most of the functions you wrote this week:
function run(genFn) {
return new Promise((resolve, reject) => {
const gen = genFn();
function step(method, arg) {
let result;
try {
result = gen[method](arg); // gen.next(value) or gen.throw(error)
} catch (err) {
return reject(err); // generator threw and nothing caught it
}
const { value, done } = result;
if (done) {
return resolve(value); // generator returned: settle the outer promise
}
// Treat whatever was yielded as a promise. Wait, then resume.
Promise.resolve(value).then(
(v) => step('next', v), // resolved: feed the value back in
(e) => step('throw', e), // rejected: throw it at the yield point
);
}
step('next', undefined); // kick it off
});
}
run takes a generator function and returns a promise. That promise stands in for the whole async operation, the same way calling an async function hands you a promise.
Inside, step is the engine. It calls the generator (gen.next(arg) to resume normally, gen.throw(arg) to inject an error, and we'll get to why that matters).
The generator hands back { value, done }. If done is true, the generator has returned, so we resolve the outer promise with whatever it returned.
If it isn't done, then value is whatever got yielded, which we are choosing to treat as a promise. We wrap it in Promise.resolve so plain values work too, wait for it with .then, and when it settles we call step again to wake the generator up. A resolved promise resumes with .next(theValue). A rejected one resumes with .throw(theError).
Then step('next', undefined) starts the machine. Everything after that is the generator and the promises bouncing control back and forth until done.
Here is what using it looks like next to the native version:
// native
async function nativeSequential() {
const a = await wait(10, 2);
const b = await wait(10, 3);
return a + b;
}
// our version: function* and yield instead of async and await
function genSequential() {
return run(function* () {
const a = yield wait(10, 2);
const b = yield wait(10, 3);
return a + b;
});
}
Swap async for function* wrapped in run, swap await for yield, and the two functions are the same shape. That's not a coincidence. We'll get to why in a minute.
Why this Works
The runner you just read is not a clever approximation of async/await. It is, give or take some edge-case handling, how async/await actually shipped.
When async functions were proposed for JavaScript, the reference implementation compiled them down to generators driven by a runner, using a tool called regenerator. The proposal itself was built on top of generators and promises, because those two features together already had everything async functions needed. The pause came from generators, the waiting came from promises, and a small driver glued them.
It went further than a proposal. For years, if you wrote async/await and compiled it with TypeScript or Babel to run on older browsers, the output was a generator and a helper function. TypeScript's helper is called __awaiter, and if you read its source, it is the same code you just walked through: a new Promise, a step function, generator .next(value) when a promise resolves, generator.throw(value) when one rejects, resolve when the generator is done. Before the keywords even existed, libraries like co and Bluebird's coroutine handed people this exact pattern so they could write flat, sequential-looking async code using yield.
So the twenty lines above aren't a model of async/await. They're closer to a fossil of it. You rebuilt the thing the feature was made from.
Where the analogy stops
It would be factually inaccurateto say that run is a drop-in replacement for the real keyword, and the honest gaps are worth knowing:
Modern engines don't ship your generator runner. V8 has native async functions now, with their own optimized handling of the microtask queue, so the exact scheduling of when continuations fire is tuned in ways a hand-written .then loop doesn't perfectly reproduce. In ordinary code you won't see a difference, but if you're reasoning about precise microtask ordering across many interleaved tasks, the native version is the source of truth, not this.
The runner is also missing the rough edges a real implementation handles.
The right takeaway from this document is that the implementation we developed is not a code to ship, but rather a model that turns a word you used on faith into a thing you can reason about.
The bit underneath
The two-way communication that makes this work, the .next(value) injection, is one of the most underused features in the language, and it powers more than this. The same back-and-forth drives yield* delegation, lets generators model state machines, and is the foundation the whole async story was built on. I pulled that full layer apart, the bidirectional protocol, yield*, and the runner that grew into async/await, in a short free book on generators. If this post made the mechanism click and you want the complete mental model beneath it, grab it:
Cheers :)




Top comments (0)