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 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
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
Cheers :)

SOCIAL SHARE CARD GENERATOR