How async code is compiled down
Learn how transpilers turn generators and async functions into resumable state machines, runners, and compatibility code for older browsers.
- 01Read a state machineMatch a generator's yields to a small switch statement that saves its next state.
- 02Explain lowered asyncDescribe how a generator plus a promise runner can preserve async function order.
- 03Choose a targetRecognize when older output adds helpers, runtime code, allocations, and debugging work.
What compiled down means
Modern JavaScript engines understand async, await, and generators. Older browsers did not. A transpiler is a tool that rewrites newer source code into older JavaScript while trying to keep the same visible behavior.
Compiled down means rewritten into features supported by an older target. For async code, that often means a resumable generator or state machine plus promise-handling helper code.
The rewrite does not make time disappear. It represents each pause explicitly. Local values need a place to wait, a label needs to say where execution should continue, and a promise callback needs to restart the work when a result arrives.
This continues from the microtask queue. That lesson explains where promise jobs wait. This lesson shows the extra JavaScript a build tool can create before those promise jobs get involved.
A generator pauses at yield
Start with a generator because it makes pausing visible. A generator function has a star after function. Calling it creates an iterator, but does not run the body all the way through.
function* makeTea() { yield "boil water"; yield "add tea"; yield "pour";} const tea = makeTea();console.log(tea.next().value);console.log(tea.next().value);console.log(tea.next().value);Line 1 declares the generator. Lines 2 through 4 each pause at yield. Line 7 creates one generator object. Lines 8, 9, and 10 call next(), so the console prints boil water, add tea, and pour in that order.
A call to next() returns an object such as { value: "boil water", done: false }. The value is the pause point's message. The done flag stays false until the generator has no more work.
Replay a real generator run. This is an instrumented lesson example, not an engine debugger.
script
function* makeTea() { yield "boil water"; yield "add tea"; yield "pour";} console.log(tea.next().value);console.log(tea.next().value);console.log(tea.next().value);Generators as state machines
A state machine is ordinary code that remembers its current step and chooses what to do next. It is a useful model for a generator because a generator must remember which yield it reached before it pauses.
function makeTeaMachine() { let state = 0; return { next() { switch (state) { case 0: state = 1; return { value: "boil water", done: false }; case 1: state = 2; return { value: "add tea", done: false }; case 2: state = 3; return { value: "pour", done: false }; default: return { value: undefined, done: true }; } }, };}Line 2 starts the sticky note at state 0. The first next() takes line 6, returns boil water, and changes the saved state to 1. The second call takes line 7. The third takes line 8. A fourth call reaches the default branch and reports done.
Think of a recipe with numbered steps and a sticky note saying where you stopped. Each time you come back, read the note, do that step, and move the note. The state machine is the recipe plus that note.
- In real life: A recipe has numbered steps
- In JavaScript: A function body has pause points
- In real life: A sticky note says step 3
- In JavaScript: A saved state label says where to continue
- In real life: You return to the recipe later
- In JavaScript: A later
next()resumes work - In real life: You update the note after a step
- In JavaScript: The machine saves the next state
Where the analogy stops: A recipe does not execute itself. In JavaScript, a caller or promise machinery chooses when to resume.
This is a teaching model, not literal engine source. Real engines have optimized internal representations. The important connection is simple: a pausable function needs saved state, and a switch is one direct way to express saved state in ordinary JavaScript.
Replay a hand-written state machine. It has the same outside behavior as the three-yield generator.
script
function makeTeaMachine() { return { next() { switch (state) { case 0: state = 1; return { value: "boil water", done: false }; case 1: state = 2; return { value: "add tea", done: false }; case 2: state = 3; return { value: "pour", done: false }; default: return { value: undefined, done: true }; } }, };}Compare generator and machine
The two versions look different inside, but they can offer the same outside behavior. Each call gets the next tea instruction. The model also reveals a number that a normal generator keeps hidden: its current state.
function* makeTea() { yield "boil water"; yield "add tea"; yield "pour";} const tea = makeTea();console.log(tea.next().value);console.log(tea.next().value);console.log(tea.next().value);function makeTeaMachine() { let state = 0; return { next() { switch (state) { case 0: state = 1; return { value: "boil water", done: false }; case 1: state = 2; return { value: "add tea", done: false }; case 2: state = 3; return { value: "pour", done: false }; default: return { value: undefined, done: true }; } }, };}state 1Generator: {"value":"boil water","done":false}
Machine: {"value":"boil water","done":false}
After call 1, both versions return "boil water" and the machine saves state 1.
Try one call first. Both versions return boil water, while the machine saves state 1. Try three calls to reach pour. Try four calls to see done: true. Reset returns the comparison to its first call.
| Thing | Who resumes it? | What it exposes |
|---|---|---|
| Generator | You call next() to resume it. | Its yielded value and done flag. |
| State machine | An ordinary next() method reads a saved label. | The same next value and a later saved state. |
| Async function | Promise machinery resumes it after an awaited promise settles. | A promise for its final return value. |
| Down-leveled async | A generated runner drives a generated generator or state machine. | The original async order, with extra helper code. |
A compiler can choose different generated shapes, but it needs this same information somewhere. It must know the next label, preserve locals that will be read later, and return or schedule the next result without repeating earlier steps.
Regenerator and async-to-generator
Regenerator is the name commonly used for Babel's generator transform and runtime support. In plain words, it rewrites a generator body into a switch over a saved label, often named context.next, while saving values that later steps still need.
// Simplified sketch of Regenerator output; not runnable by itself.function makeTea(context) { while (1) switch (context.next) { case 0: context.next = 2; return "boil water"; case 2: context.next = 4; return "add tea"; case 4: return context.stop("pour"); }}This sketch is deliberately simplified and is not runnable by itself. Line 3 reads the next label. Lines 4 through 6 select a branch, set the following label, and return a value. Real generated output also handles errors, return, throw, and more complex control flow.
Async-to-generator is the next layer. It rewrites an async function into a generator whose yielded values are promises. A small helper drives that generator: when a promise settles, it resumes the generator with the settled value.
async function makeOrder() { console.log("start order"); const item = await Promise.resolve("tea"); console.log("serve " + item);} makeOrder();Line 2 prints before the pause. Line 3 awaits a promise for tea. When that promise settles, line 4 prints serve tea. The next section builds the tiny helper that preserves that order using a generator.
A tiny async-to-generator runner
The runner is small because the promise already knows how to schedule its continuation. It starts the generator, checks whether it is done, and otherwise waits for the yielded promise before calling next(value) again.
function run(genFn) { const iterator = genFn(); function advance(value) { const step = iterator.next(value); if (step.done) return Promise.resolve(step.value); return Promise.resolve(step.value).then(advance); } return advance();} function* makeOrder() { console.log("start order"); const item = yield Promise.resolve("tea"); console.log("serve " + item);} run(makeOrder);Line 2 creates the iterator. Line 4 resumes it. Line 5 ends the outer promise when the generator finishes. Line 6 wraps the yielded value as a promise and calls advance after it settles. Lines 11 through 14 are the lowered function body: it yields the promise, then receives the settled item on a later resume.
The real async function and this runner print the same order: start order followed by serve tea. The test runs both snippets with asynchronous snippet support so this claim comes from real promise scheduling, not a hand-written expected string.
Coroutines keep their local state
A coroutine is a function that can pause and later resume while keeping its local state. The word describes the behavior, not a particular syntax. A generator is a coroutine you drive with next(); an async function is a coroutine promise machinery drives after awaited work settles.
function* countOrders() { let order = 1; while (order <= 3) { yield "order " + order; order += 1; }} const orders = countOrders();console.log(orders.next().value);console.log(orders.next().value);Line 2 creates the local order once. Line 4 yields order 1. When line 10 calls next(), execution returns after that yield, increments the same local variable on line 5, and prints order 2. It did not start at line 1 again.
This is why generated output has to preserve locals. In the async example, the eventual item becomes a value available after the pause. A state-machine transformation stores enough information to make the later branch behave as if the original frame stayed open.
The cost of down-leveled async
Down-leveling solves a compatibility problem, but it adds code. A short native async function can become a generated generator or switch, a runner helper, promise continuations, saved locals, and sometimes a Regenerator runtime shared across the bundle.
async function saveOrder(order) { const receipt = await send(order); return receipt.id;} // Older targets can add a runner, a generator body,// saved state, and sometimes a Regenerator runtime.Line 1 is compact native source. Line 2 pauses for send(order). A legacy transform must represent that pause and the later receipt local. The comments name the extra pieces without pretending that every compiler emits identical code.
| Area | What can change | Practical response |
|---|---|---|
| Bundle size | Helpers and sometimes a runtime are emitted. | Target modern browsers when your audience allows it. |
| Runtime work | Each pause can add promise callbacks, function calls, and objects. | Treat this as qualitative cost unless you measure your target. |
| Debugging | Generated switch labels do not resemble the source directly. | Use source maps and inspect the configured output target. |
| Compatibility | Older environments can run syntax they do not natively support. | Balance support needs against the extra output. |
There can be extra function calls and allocations at each represented pause. Debugging generated branches is harder because source line 3 may become several generated locations. Source maps help connect generated output back to the code you wrote.
Choosing your output target
Do not lower syntax by habit. First decide which browsers, embedded webviews, or runtimes your product must support. Modern browsers support async and await natively, so a modern target can keep the clear source you wrote.
A project with older support needs may accept generated helpers and a runtime. That is a valid trade-off when it lets users run the product. The important part is making the target deliberate, then checking output and bundle size instead of relying on a slogan about performance.
- Set browser support targets before comparing bundle output.
- Inspect the generated code when a stack trace looks unfamiliar.
- Keep source maps available for development and production debugging policy.
- Read transpilers and polyfills for the compatibility decision itself.
- Use native async where the supported audience can run it.
This does not mean native output is always best for every deployment. It means compatibility code should answer a real audience need, and its bundle and debugging cost should be part of that decision.
yield "add tea"pauses and returns a value.- A saved
statechooses aswitchbranch. Promise.resolve(step.value).then(advance)resumes work.- The settled value becomes the result of a paused
yield. - Browsers support
asyncandawaitnatively. - A legacy target may emit helpers and a runtime.
Place each card by the job it performs in the tea examples.
Common misconceptions
- “A generator runs in parallel.” It pauses and resumes when its caller invokes
next(). - “A state machine is fake async.” It is one way to represent a real pause and continuation in ordinary code.
- “Every compiler emits Regenerator.” Output depends on the tool, configuration, and target.
- “Down-leveling has one fixed cost.” Code shape and runtime behavior vary, so measure the target workload.
- “Async functions lose locals while awaiting.” Their resumable frame preserves what later code needs.
| Term | What it means | Do not confuse it with |
|---|---|---|
| A state machine | Saved state plus branches that decide where to continue. | A special JavaScript engine feature only compilers can create. |
| Regenerator | A Babel runtime and transform pattern for generator behavior. | The exact code emitted by every JavaScript compiler. |
| A coroutine | A computation that can pause and resume with local state intact. | Only a function that runs on another thread. |
| Native async | Modern engines implement async and await directly. | Always slower than generated code or always identical to it. |
The safe mental model is modest. A compiler can rewrite pauses into labels, saved values, and callbacks. The runtime still follows the promise and microtask rules from the previous lesson; generated code does not create a separate hidden clock.
Practice exercises
Read the tiny generator and type what the console prints.
function* makeTea() {
yield "boil water";
yield "add tea";
}
const tea = makeTea();
console.log(tea.next().value);It prints boil water. The generator pauses at its first yield and exposes that value through the iterator result.
After one call to the tea machine, what saved state does it report?
const machine = makeTeaMachine();
console.log(machine.next().value);
console.log(machine.getState());The machine returns boil water and then reports state 1. Its next call will choose the second branch.
Run the lowered-style example mentally. Type the two logged messages in order.
function run(genFn) {
const iterator = genFn();
function advance(value) {
const step = iterator.next(value);
return step.done ? Promise.resolve(step.value) : Promise.resolve(step.value).then(advance);
}
return advance();
}
function* work() {
console.log("start");
const item = yield Promise.resolve("tea");
console.log("finish " + item);
}
run(work);The output order is start, then finish tea. The promise continuation resumes the generator with tea.
Which piece resumes a generator when it is standing in for an async function?
A promise runner drives the generator. It waits for a yielded promise and resumes with its settled value.
Your team is shipping a dashboard to modern desktop browsers and an older embedded webview. What should you check before forcing legacy async output everywhere?
Check browser support or the configured target browsers. Then decide whether native async is safe for the actual audience.
A production stack trace points at generated helper code. What tool helps map that location back to your original async function?
Use source maps. They let developer tools and stack traces point from generated compatibility code back to your async source.
Check your understanding
Use the tea examples to separate three ideas: what pauses, who resumes it, and what older output must add. Read code questions one pause at a time instead of guessing from the name of a transform.
Question 1 of 7What does
yielddo in the tea generator?Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictfunction* tea() { yield "pour"; } console.log(tea().next().value);Choose an answer to see the explanation.
Question 3 of 7Why does the hand-written machine keep
state?Choose an answer to see the explanation.
Question 4 of 7What does an async-to-generator runner do after a yielded promise settles?
Choose an answer to see the explanation.
Question 5 of 7Which statement describes a coroutine in this lesson?
Choose an answer to see the explanation.
Question 6 of 7What is a likely qualitative cost of down-leveling async?
Choose an answer to see the explanation.
Question 7 of 7When can a project usually avoid generated async helpers?
Choose an answer to see the explanation.
Key takeaways
- A generator pauses at each yield and resumes when someone calls next.
- A state machine can model that behavior with a saved label and a switch.
- Regenerator-style output represents generator pauses with labels and saved locals.
- Async-to-generator uses a promise runner to resume after each yielded promise settles.
- Coroutines pause and resume while preserving local state; generators and async functions differ in who drives them.
- Down-leveled async can add bundle code, calls, allocations, and debugging work, so choose modern targets when support allows it.
Remember the one-liner.
A transpiler turns a pause into saved state plus code that knows how to resume it.
Coming next: Writing engine-friendly code. You will move from how code is represented to practical patterns that help engines stay on fast paths.