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 each
yieldin a generator to a branch of a smallswitchthat saves the next step. - 02Explain rewritten asyncSay how a generator plus a promise runner keeps an async function's order.
- 03Choose a targetSpot when older output adds helper code, extra work, and harder debugging.
What compiled down means
Modern JavaScript engines understand async, await, and generators. Very old browsers do not. A transpiler is a tool that rewrites new JavaScript into older JavaScript. The goal is that the program still does the same things, in the same order.
Compiled down means rewritten into older JavaScript that an older browser can run. For async code, each pause becomes ordinary code: a saved step number, plus helper code that starts the work again later.
The rewrite cannot skip the waiting. Instead, it writes out each pause in plain code. Each pause needs three things:
- a place to keep the values the function still needs;
- a note that says which step comes next;
- a promise callback that starts the work again when the result arrives. A callback is a function you hand over so other code can call it later.
This lesson follows on from the microtask queue. That lesson showed where promise jobs wait. This lesson shows the extra JavaScript a build tool can write to make pauses work in older browsers.
A generator pauses at yield
Start with a generator, because it shows pausing clearly. A generator is a function that can stop in the middle and carry on later. You write it with a star after the word function: function*. Inside it, yield marks a pause point. There, the function hands back a value and stops.
Calling a generator does not run its body. It gives you a generator object. Each time you call the object's next() method, the body runs up to the next yield and stops again.
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 by line:
- Line 1 starts the generator
makeTea. - Lines 2, 3, and 4 each have a
yield. Each one is a pause point with one tea step. - Line 7 calls
makeTea(). This makes one generator object,tea. No step runs yet. - Lines 8, 9, and 10 each call
tea.next(). The console printsboil water,add tea, andpour, in that order.
next() runs the generator up to the next yield, then it pauses again. Three calls give the three steps, in order.Each call to next() returns a small object, such as { value: "boil water", done: false }. It has two parts:
valueis what theyieldhanded back.donesays whether the generator has finished. It staysfalseuntil there are no steps left.
Lines 8 to 10 print only the value part.
Step through a real generator run. This records the lesson's own example step by step; it is not the engine's 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 which step it is on. Each time you call it, it looks at the saved step, does that step, and saves the next one. This is a good way to picture a generator, because a generator also has to remember which yield it stopped at.
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 }; } }, };}Here state is a plain number. A switch picks one branch, one case, by that number. Line by line:
- Line 2 sets
stateto 0, so the first call starts at the first step. - The first
next()takes line 6. It setsstateto 1 and returnsboil water. - The second call takes line 7. It sets
stateto 2 and returnsadd tea. - The third call takes line 8. It sets
stateto 3 and returnspour. - A fourth call finds no
casefor 3. It takes thedefaultbranch on line 9 and returnsdone: true.
state, which is 0, so it runs the step for 0 and returns boil water. Before it returns, it writes 1 into state, so the next call runs the next step.Think of a recipe with numbered steps. A sticky note on the page says where you stopped. Each time you come back, you read the note, do that step, and move the note to the next step. The state machine is the recipe plus that note.
- In real life: A recipe has numbered steps
- In JavaScript: The function body has pause points
- In real life: A sticky note says step 3
- In JavaScript: The saved
statenumber says where to carry on - In real life: You come back to the recipe later
- In JavaScript: A later
next()call carries on - In real life: You move the note after each step
- In JavaScript: Each branch saves the next
state
This machine is a model for learning. It is not the code inside a real engine; engines store paused functions in their own, faster way. The main point is simple: a function that can pause must save where it stopped. A saved number plus a switch is one way to do that in ordinary JavaScript.
Step through the hand-written state machine. It gives the same results as the generator with three yields.
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 the generator and the machine
Inside, the two versions look different. From the outside, they act the same: each call gives the next tea step. The machine also shows one thing a real generator keeps hidden: its saved state number.
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 these:
- 1 call: both return
boil water, and the machine saves state 1. - 3 calls: both reach
pour. - 4 calls: both return
done: true, because no steps are left. - Reset goes back to 1 call.
The table below compares four kinds of code that can pause. Read each row as: what it is, who starts it again, and what you get back.
| What it is | Who starts it again? | What you get back |
|---|---|---|
| A generator. | You do, by calling next(). | The value its yield hands back, and a done flag. |
| A state machine. | An ordinary next() method reads a saved step number. | The same value, and a new saved step number for next time. |
| An async function. | Promises do, when the promise it waits for is ready. | A promise for its final return value. |
| Older async output. | A runner, written by the build tool, drives a rewritten generator or state machine. | The same order as the original async function, with extra helper code. |
A build tool can write its output in different shapes. But every shape must keep the same three facts:
- which step runs next;
- the local variables (variables made inside the function) that later steps still read;
- how to return the next result without running earlier steps again.
Regenerator and async-to-generator
Regenerator is a tool used by Babel, a popular transpiler. Regenerator rewrites a generator into ordinary code. It works like the tea machine: a switch reads a saved step number, often called context.next. It also saves the values that later steps still need.
// Simplified sketch of Regenerator output. It does not run on its own.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 cut down on purpose, and it does not run on its own. Line by line:
- Line 2 starts the rewritten
makeTea. It gets acontextobject that holds the saved step number. - Line 3 reads the saved step number,
context.next, and picks a branch. - Lines 4 and 5 each save the following step number and return one tea step.
- Line 6 returns the last step,
pour, and marks the generator as finished.
Real output does more. It also handles errors, an early return, throw, and loops.
Async-to-generator is the next step. It rewrites an async function into a generator. Each await becomes a yield that hands out a promise. A small helper, called a runner, drives the generator. When a promise is ready, the runner starts the generator again and gives it the promise's value.
async function makeOrder() { console.log("start order"); const item = await Promise.resolve("tea"); console.log("serve " + item);} makeOrder();Line by line:
- Line 2 prints
start orderbefore the pause. - Line 3 waits for a promise that holds
"tea". - When that promise is ready, line 4 prints
serve tea. - Line 7 calls the function.
The next section builds a small runner that keeps this same order, using a generator.
A tiny async-to-generator runner
The runner can be small, because a promise already knows how to call code later. The runner does three jobs:
- It starts the generator.
- It checks whether the generator has finished.
- If not, it waits for the promise the generator handed out. Then it calls
next(value)with that promise's value.
function run(genFn) { const iterator = genFn(); function advance(method, value) { let step; try { step = iterator[method](value); } catch (error) { return Promise.reject(error); } if (step.done) return Promise.resolve(step.value); return Promise.resolve(step.value).then( value => advance("next", value), error => advance("throw", error), ); } return advance("next");} function* makeOrder() { console.log("start order"); const item = yield Promise.resolve("tea"); console.log("serve " + item);} run(makeOrder);Line by line:
- Line 2 calls the generator function. This gives the generator object,
iterator. - Line 3 makes
advance, the function that moves the generator one step. - Line 6 starts the generator again, with either
nextorthrow. - Line 10: when the generator has finished, the runner finishes its own promise with the final value.
- Lines 11 to 14: when the promise is ready, its value goes back in through
next(value). If the promise fails, the error goes back in throughthrow(error). That way, atry/catcharound the originalawaitstill works. - Line 16 makes the first move, with
next. - Lines 19 to 22 are the async function, rewritten as a generator. Line 21 hands out the promise. On a later call,
itemgets"tea". - Line 25 starts everything.
next("tea"), and the generator goes on with item set to "tea".The real async function and this runner print the same two lines, in the same order: start order, then serve tea. The lesson's tests run both programs with real promises to check this.
Coroutines keep their local state
A coroutine is a function that can pause and carry on later. While it is paused, it keeps its local variables. The word names what the function does, not a kind of syntax. Both of these are coroutines:
- A generator. You drive it by calling
next(). - An async function. Promises drive it: it carries on when the promise it waits for is ready.
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 by line:
- Line 2 makes the local variable
orderand sets it to 1. This happens only once. - Line 9 makes the generator object,
orders. - Line 10 calls
next(). Line 4 hands backorder 1and pauses, and line 10 prints it. - Line 11 calls
next()again. The generator carries on right after theyield. - Line 5 adds 1 to the same
ordervariable. The loop goes round, line 4 hands backorder 2, and line 11 prints it.
The generator never went back to line 1, so order was not set back to 1.
This is why rewritten code must save local variables. In the async example, item gets its value after the pause. The rewritten code stores enough to make the later branch act as if the function had never stopped.
The cost of older async output
Rewriting for older browsers solves one problem: old browsers can run the code. But it adds code. One short async function can turn into several pieces:
- a generator body, or a
switchlike the tea machine; - a runner;
- promise callbacks and saved local variables;
- sometimes a shared helper library, called the Regenerator runtime. It goes into the bundle: the JavaScript file your site sends to the browser.
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 shared helper library.Line by line:
- Line 1 starts a short
asyncfunction. - Line 2 pauses until
send(order)is ready, and saves the result inreceipt. - Line 3 reads
receiptafter the pause. So older output must keepreceiptsafe across the pause. - The comments on lines 6 and 7 name the extra pieces. Different tools write different code, so the sketch does not show exact output.
The table below lists four kinds of cost. Read each row as: what it affects, what can change, and what you can do. It gives no fixed numbers, because the real cost depends on your code and your browsers.
| What it affects | What can change | What you can do |
|---|---|---|
| File size. | Helper code, and sometimes a shared helper library, go into the bundle. | Build for modern browsers when your users allow it. |
| Work while running. | Each pause can add promise callbacks, function calls, and objects. | Measure on your own browsers before you claim a number. |
| Debugging. | The rewritten switch steps do not look like the code you wrote. | Use source maps, and look at the output your build really makes. |
| Which browsers can run it. | Older browsers can run code they could not run before. | Weigh the users you gain against the extra code. |
Each pause can add extra function calls and extra objects. Debugging gets harder, too: one line you wrote, like line 2, can turn into several places in the rewritten code. A source map is a file that links the rewritten code back to the lines you wrote. With it, developer tools can show your own code.
Choosing which browsers to build for
Do not rewrite async code out of habit. First, decide where your product must run:
- which browsers;
- which web views (a small browser built into an app);
- which runtimes (programs that run JavaScript outside a browser, such as Node.js).
This list is your target. All modern browsers run async and await as they are. So a modern target can keep the clear code you wrote.
Some projects must support older browsers. Then the extra helper code is a fair price, because it lets those users run the product. The important part is to choose the target on purpose. Then check the output and the bundle size yourself, instead of guessing.
- Set your browser targets before you compare bundle output.
- Look at the rewritten code when a stack trace (the list of function calls shown with an error) looks strange.
- Keep source maps for debugging, both while you build and, if your team allows it, on the live site.
- Read transpilers and polyfills for how to make the support decision.
- Use native
asyncwherever all your users' browsers can run it.
This does not mean modern output is always right for every site. It means helper code should be there because real users need it. Its size and its debugging cost should be part of that choice.
yield "add tea"pauses and hands back a value.- A saved
statepicks aswitchbranch. Promise.resolve(step.value).then(advance)starts the work again.- The promise's value becomes the result of a paused
yield. - The browsers run
asyncandawaitas they are. - An older target may add helper code and a helper library.
Put each card under the part that does that job in the tea examples.
Common misconceptions
- “A generator runs in parallel, at the same time as other code.” It runs only when someone calls
next(), and it stops at eachyield. - “A state machine is fake async.” It is a real way to write a pause, and the later restart, in ordinary code.
- “Every transpiler outputs Regenerator code.” The output depends on the tool, its settings, and the target.
- “Older output always costs the same fixed amount.” The code and its speed vary, so measure on your own target.
- “Async functions lose their local variables while they wait.” The paused function keeps everything that later lines need.
The table below lists words that are easy to mix up. Read each row as: the word, what it means, and the wrong idea people often have about it.
| Word | What it means | What it does not mean |
|---|---|---|
| State machine. | A saved step number, plus branches that pick where to carry on. | A special engine feature that only build tools can make. |
| Regenerator. | A Babel tool, plus a helper library, that rewrites generators. | The exact code that every JavaScript build tool writes. |
| Coroutine. | A function that can pause and carry on, and keeps its local variables. | A function that runs on another thread, at the same time as other code. |
| Native async. | Modern engines run async and await as they are, with no rewrite. | Always slower than rewritten code, or always exactly the same speed. |
Keep the picture simple. A transpiler can rewrite pauses into step numbers, saved values, and callbacks. The program still follows the promise and microtask rules from the last lesson. (A microtask is a small job that runs as soon as the current code finishes.) Rewritten code does not get a secret clock of its own.
Practice exercises
Read the small generator. 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 runs to its first yield, hands back that value, and pauses.
After one call to the tea machine, what state number does it report?
const machine = makeTeaMachine();
console.log(machine.next().value);
console.log(machine.getState());The machine returns boil water, then reports state 1. Its next call will take the second branch.
Follow this runner program in your head. Type the two printed lines, 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);It prints start, then finish tea. When the promise is ready, the runner starts the generator again with "tea".
A generator can stand in for an async function. Which piece starts that generator again after each pause?
A promise runner. It waits for the promise the generator hands out, then starts the generator again with that promise's value.
Your team is building a dashboard. It must run in modern desktop browsers and in an older web view inside an app. What should you check before you force older async output everywhere?
Check which browsers you must support: your target browsers. Then decide whether native async is safe for those real users.
An error on your live site shows a stack trace that points into rewritten helper code. What tool helps you find the matching line in your original async function?
Use source maps. They let developer tools and stack traces point from the rewritten code back to your async function.
Check your understanding
Use the tea examples to keep three questions apart: what pauses, who starts it again, and what older output has to add. For code questions, go one pause at a time instead of guessing.
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 7A generator hands an async-to-generator runner a promise. What does the runner do when that promise is ready?
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 cost of rewriting async code for older browsers?
Choose an answer to see the explanation.
Question 7 of 7When can a project usually skip the async helper code?
Choose an answer to see the explanation.
Key takeaways
- A generator pauses at each
yieldand carries on when someone callsnext(). - A state machine can do the same job with a saved step number and a
switch. - Regenerator-style output turns generator pauses into step numbers and saved local variables.
- Async-to-generator uses a promise runner. The runner starts the generator again each time a promise is ready.
- Coroutines pause and carry on while keeping their local variables. Generators and async functions differ in who starts them again.
- Older async output can add bundle code, calls, objects, and debugging work. Choose a modern target when your users allow it.
Remember the one-liner.
A transpiler turns each pause into a saved step, plus code that knows how to carry on from it.
Coming next: Writing engine-friendly code. You will learn simple code habits that help the engine run your code fast.