--- title: "How async/await Really Works: From Syntax Sugar to State Machines" author: "Alysaa Lissy" published: "2026-11-10T10:38:03.786Z" updated: "2026-11-10T10:38:03.786Z" url: "https://zyvop.com/how-async-await-really-works-from-syntax-sugar-to-state-machines-zclcn" canonical: "https://zyvop.com/how-async-await-really-works-from-syntax-sugar-to-state-machines-zclcn" tags: ["promises","Async","JavaScript","event-loop","internals"] --- # How async/await Really Works: From Syntax Sugar to State Machines Explore how JavaScript transforms async/await into resumable state machines using Promises, microtasks, and the event loop. ![Cover image]() > **TL;DR** > > - An `async` function runs **synchronously** until its first `await`, then hands back a Promise. > > - `await` **pauses the function** and schedules "the rest of the function" to run as a **microtask** once the awaited promise settles. > > - Under the hood, the function becomes a **resumable state machine**. No threads are involved. > > - The microtask queue explains almost every "weird" ordering bug you will ever meet. > Before we explain anything, predict the output of this snippet. Write your answer down; we will check it in [section 6](). Before we explain anything, predict the output of this snippet. Write your answer down; we will check it in [section 6](). ```javascript console.log('1: sync start'); setTimeout(() => console.log('2: timeout'), 0); (async () => { console.log('3: inside async, before await'); await null; console.log('4: after await'); })(); Promise.resolve().then(() => console.log('5: promise.then')); console.log('6: sync end'); ``` If your answer was "1, 3, 6, 5, 4, 2" or "1, 3, 4, 6, 5, 2", this post is for you. By the end you will not just know the right order, you will be able to derive it for any snippet. * * * ## 1\. The problem async/await solves JavaScript runs your code on **one thread**. If that thread waits for a network response, nothing else can happen: no clicks, no timers, no rendering. So waiting must never block. Instead, we start an operation, say what to do when it finishes, and get out of the way. Three generations of syntax have expressed that idea: ```javascript // 2009: callbacks getUser(id, (err, user) => { if (err) return handle(err); getPosts(user, (err, posts) => { if (err) return handle(err); render(posts); }); }); // 2015 (ES2015): promises getUser(id).then(getPosts).then(render).catch(handle); // 2017 (ES2017): async/await (inside an async function or an ES module) try { const user = await getUser(id); const posts = await getPosts(user); render(posts); } catch (err) { handle(err); } ``` All three run on the same mechanism. Each new syntax is a thinner layer over the one below it: ```mermaid flowchart TB A["async / await
syntax that pauses and resumes a function"] B["Promises
an object representing a value that arrives later"] C["Event loop + microtask queue
decides which callback runs next"] D["Host runtime
timers, network, disk (browser or libuv)"] A -->|built on| B B -->|built on| C C -->|built on| D ``` We will build up from the bottom. * * * ## 2\. Layer 1: the event loop and its two queues The runtime has one call stack and two queues that feed it: - **Task queue** (a.k.a. macrotasks): timer callbacks, I/O completions, UI events. - **Microtask queue**: promise reactions (`.then`, `.catch`, `.finally`), `await` continuations, and `queueMicrotask()` callbacks. One turn of the event loop looks like this: ```mermaid flowchart TD A([Start of loop iteration]) --> B["Take ONE task from the task queue"] B --> C["Run it until the call stack is empty"] C --> D{"Microtask queue empty?"} D -- No --> E["Run the next microtask"] E --> D D -- Yes --> F["Browser only: maybe render a frame"] F --> A ``` The rule that matters most in this entire post: > **After every task, the engine drains the _entire_ microtask queue, including microtasks queued by other microtasks, before it takes the next task.** Think of a clerk at a counter. Each customer (a task) is served completely. Any follow-up paperwork that customer generates (microtasks) is finished before the next customer is called. > **Node.js note:** Node adds `process.nextTick`, which has its own queue that runs just ahead of promise microtasks. The mental model is the same. * * * ## 3\. Layer 2: promises in 60 seconds A promise is a small state machine with exactly three states, and it can leave `pending` only once: ```mermaid stateDiagram-v2 [*] --> Pending Pending --> Fulfilled: resolve(value) Pending --> Rejected: reject(reason) Fulfilled --> [*] Rejected --> [*] ``` The behavior that trips people up is that `.then` **callbacks are never called synchronously**, even if the promise is already fulfilled. They are always queued as microtasks: ```javascript const p = Promise.resolve(42); p.then((v) => console.log('then', v)); console.log('sync'); // Output: // sync // then 42 ``` Hold on to that. `await` is built on exactly this behavior. * * * ## 4\. Layer 3: what `async` and `await` do ### Two rules of `async` functions 1. **It always returns a promise.** A returned value fulfills it, and a thrown error rejects it. 2. **The body runs synchronously until the first** `await`**.** Nothing is deferred just because the function is marked `async`. ```javascript async function demo() { console.log('A'); await somethingAsync(); console.log('B'); } console.log('before'); demo(); console.log('after'); // before // A <- ran synchronously, inside the call to demo() // after // B <- ran later, as a microtask continuation ``` ### What `await` does When execution reaches `await expr`, the engine: 1. Evaluates `expr` and wraps the result in a promise (`Promise.resolve(expr)`). 2. **Suspends** the function, saving its local variables and its position. 3. **Returns** to the caller, which now holds a pending promise and keeps running. 4. Subscribes "resume me here" to the awaited promise. 5. When that promise settles, a **microtask** resumes the function with the result (or throws the error at the `await` site). ```mermaid sequenceDiagram participant Caller participant Fn as async function participant Net as Network participant MQ as Microtask queue Caller->>Fn: getUserName(1) Fn->>Net: fetch() starts Fn-->>Caller: await: suspend and return a pending Promise Note over Caller: Caller keeps running synchronously Net-->>MQ: response arrives, promise fulfills, continuation queued MQ->>Fn: run continuation Fn->>Fn: resume right after the await Fn-->>Caller: outer promise fulfills with the result ``` For simple, linear code, you can think of `await` as `.then` with the rest of the function as the callback: ```javascript async function getUserName(id) { const res = await fetch(`/api/users/${id}`); const user = await res.json(); return user.name; } // Roughly equivalent to: function getUserName(id) { return fetch(`/api/users/${id}`) .then((res) => res.json()) .then((user) => user.name); } ``` But that equivalence breaks down quickly. What about loops containing `await`? `try/catch` around an `await`? A variable declared before the first `await` and used after the second? A plain `.then` chain cannot express these without a lot of manual plumbing. Something has to **remember the function's state while it is paused**. * * * ## 5\. What the engine really does: suspend, save, resume Pausing a function means: 1. Copy its local variables and "current position" off the call stack into a heap object. 2. Pop the function off the stack so the caller can continue. 3. Later, restore that object and jump back to the saved position. JavaScript already had a feature that does exactly this: **generators**. Before native `async/await`, libraries like `co` and Babel's `regenerator` implemented it by driving a generator with promises: ```javascript function run(generatorFn) { return new Promise((resolve, reject) => { const gen = generatorFn(); function step(method, arg) { let result; try { result = gen[method](arg); // resume the generator } catch (err) { return reject(err); // generator threw: reject outer promise } if (result.done) return resolve(result.value); // "return" statement Promise.resolve(result.value).then( (value) => step('next', value), // fulfilled: resume with the value (err) => step('throw', err) // rejected: throw INTO the function ); } step('next'); }); } // An async function is a generator where `await` is `yield`: function getUserName(id) { return run(function* () { const res = yield fetch(`/api/users/${id}`); const user = yield res.json(); return user.name; }); } ``` Notice the `step('throw', err)` branch. It throws the rejection back _into_ the paused function at the exact `yield`, which is why an ordinary `try/catch` around an `await` works. Native engines skip the userland driver, but the idea is the same. V8, for example, reuses the same suspend/resume machinery for generators and async functions. Compilers that target languages without generators (C#, Rust, Kotlin) go one step further and **rewrite your function into an explicit state machine**. Here is a hand-written version of `getUserName`: ```javascript function getUserName(id) { // Locals that must survive across awaits live here (the "saved frame"). let state = 0; let res, user; return new Promise((resolve, reject) => { function resume(value) { try { switch (state) { case 0: // run until the first await state = 1; Promise.resolve(fetch(`/api/users/${id}`)).then(resume, reject); return; // "await" = give up the thread case 1: // resumed with the fetch result res = value; state = 2; Promise.resolve(res.json()).then(resume, reject); return; case 2: // resumed with the parsed JSON user = value; resolve(user.name); // "return" = settle outer promise return; } } catch (err) { reject(err); } } resume(); // runs synchronously until the first await, like a real async function }); } ``` > **Simplification:** this version rejects the outer promise directly on failure. A real implementation throws the error back into the function at the suspension point, so your own `try/catch` blocks can handle it, as the generator driver above does. As a diagram, the compiler turned your three-line function into this: ```mermaid stateDiagram-v2 [*] --> S0 S0 : State 0 - start S1 : State 1 - resumed with res S2 : State 2 - resumed with user S0 --> S1: fetch issued, first await S1 --> S2: res.json issued, second await S2 --> [*]: resolve outer promise with user.name ``` Each `await` is an edge between states. Between those edges, your function is just an object sitting on the heap, costing no thread and no stack frame. * * * ## 6\. Solving the puzzle Back to the opening snippet. Trace it phase by phase. **Phase 1: the synchronous run (the current task)** | Step | What happens | Microtask queue | Task queue | | --- | --- | --- | --- | | 1 | `console.log('1: sync start')` prints **1** | empty | empty | | 2 | `setTimeout(..., 0)` hands the timer to the host, which holds it for now | empty | empty | | 3 | The async IIFE starts and prints **3** (sync until first await) | empty | empty | | 4 | `await null` suspends the function and queues its continuation | `[resume async fn]` | empty | | 5 | `.then(...)` on an already-resolved promise queues its callback | `[resume async fn, then-callback]` | empty | | 6 | `console.log('6: sync end')` prints **6**; the stack is empty | same | empty | **Phase 2: drain all microtasks, in order** - Resume the async function: prints **4**. - Run the `.then` callback: prints **5**. **Phase 3: next task** - The timer has expired by now, so the host queues its callback as a task. The loop picks it up and prints **2**. **Final output: 1, 3, 6, 4, 5, 2.** Two facts did all the work: async functions run synchronously until the first `await`, and microtasks always run before the next task, even a `setTimeout(..., 0)`. * * * ## 7\. How many ticks does `await` cost? In modern engines, awaiting a native promise or a plain value costs **one microtask tick**. Before a spec change that landed in V8 7.2 and Node 12, it cost three, which is why old blog posts quote confusing orderings. Returning a promise from an `async` function is more expensive. Resolving a promise _with another promise_ needs extra jobs to unwrap it, adding **two extra ticks**: ```javascript async function returnsValue() { return 1; } async function returnsPromise() { return Promise.resolve(1); } returnsPromise().then(() => console.log('returnsPromise')); returnsValue().then(() => console.log('returnsValue')); // Output: // returnsValue <- called SECOND, but finishes first // returnsPromise ``` You should never write code that depends on exact tick counts. But knowing they exist saves hours when a log line appears "too early" or "too late." * * * ## 8\. Async is not parallel This is the most expensive misconception in the async world. `await` does not run anything on another thread. It only lets _other JavaScript_ run while _the host_ does the waiting. If you hog the thread, everything stalls: ```javascript setTimeout(() => console.log('timer fired'), 100); async function crunch() { const start = Date.now(); while (Date.now() - start < 800) {} // stand-in for a huge JSON.parse or a hot loop return 'done'; } crunch().then(console.log); // 'done' prints first, then 'timer fired' at ~800 ms instead of ~100 ms ``` ```mermaid sequenceDiagram participant T as Timer 100 ms participant L as Event loop participant J as Your JS L->>J: run task: heavy loop (800 ms) Note over J: Nothing else can run. No timers, no promises, no clicks. T-->>L: 100 ms elapsed, callback queued J-->>L: loop finishes after 800 ms L->>J: run timer callback, 700 ms late ``` For CPU-bound work, reach for Web Workers or `worker_threads`, or split the work into chunks that yield back to the event loop. * * * ## 9\. Five pitfalls that follow from the model ### Pitfall 1: accidental serial awaits ```javascript // Slow: each request waits for the previous one (~900 ms for three 300 ms calls) const results = []; for (const id of ids) { results.push(await fetchUser(id)); } // Fast: all requests in flight at once (~300 ms) const results = await Promise.all(ids.map(fetchUser)); ``` ```mermaid sequenceDiagram participant App participant A as Service A participant B as Service B participant C as Service C Note over App,C: Promise.all - all three in flight, about 300 ms total par App->>A: request and App->>B: request and App->>C: request end A-->>App: response B-->>App: response C-->>App: response ``` > Unbounded `Promise.all` over thousands of items can overwhelm an API or exhaust sockets. Use a concurrency limiter such as `p-limit`, or process in batches. ### Pitfall 2: `forEach` does not wait ```javascript items.forEach(async (item) => { await save(item); }); console.log('done'); // prints BEFORE any save finishes // Fix: await Promise.all(items.map(save)); // parallel for (const item of items) await save(item); // sequential ``` `forEach` ignores the promises returned by your callback, so there is nothing for it to wait on. ### Pitfall 3: the floating promise ```javascript async function handler() { save(item); // forgot await: errors become unhandled rejections } ``` In Node 15+, an unhandled rejection **crashes the process by default**. Enable a lint rule such as `@typescript-eslint/no-floating-promises`. ### Pitfall 4: `return` vs `return await` inside `try` ```javascript async function a() { try { return fetchData(); // the promise is returned, NOT awaited here } catch (e) { return fallback(); // never runs: the rejection escapes the try block } } async function b() { try { return await fetchData(); // awaited inside the try, so the catch works } catch (e) { return fallback(); } } ``` ### Pitfall 5: microtask starvation Because the microtask queue is drained completely before the next task, a self-perpetuating microtask loop freezes the page: ```javascript function loop() { Promise.resolve().then(loop); } loop(); // timers, clicks and rendering never get a turn ``` * * * ## 10\. The same idea in other languages The details differ, but the central trick (compile a function into a resumable state machine and let a scheduler resume it) shows up everywhere: | | JavaScript | Python (`asyncio`) | Rust | C# | | --- | --- | --- | --- | --- | | Calling an async function gives you | A Promise, **already running** | A coroutine object, **not running** until awaited or scheduled | A `Future`, **not running** until polled | A `Task`, **already running** | | Compiled to | Suspend/resume machinery in the engine | Generator-like coroutine | Compiler-generated state machine | Compiler-generated state machine (`IAsyncStateMachine`) | | Who resumes it | Built-in event loop | The `asyncio` event loop you start | An executor you pick (Tokio, async-std, ...) | Thread pool or `SynchronizationContext` | | Threads | One (plus workers) | One loop thread | Often a multi-threaded executor | Multi-threaded | Rust makes the machinery most visible. A future is _polled_ by an executor, and it returns `Pending` after telling the OS how to wake it: ```mermaid sequenceDiagram participant Ex as Executor participant F as Future (state machine) participant IO as OS reactor Ex->>F: poll() F->>IO: register interest and a waker F-->>Ex: Poll::Pending Note over Ex: Executor runs other tasks meanwhile IO-->>Ex: waker.wake() Ex->>F: poll() again F-->>Ex: Poll::Ready(value) ``` Same shape as our JavaScript diagram: suspend, subscribe, get woken, resume. The big practical difference is **eager vs. lazy**. A JavaScript promise starts working the instant you create it. A Rust future or Python coroutine does nothing until something drives it. * * * ## 11\. The mental model on one screen 1. `async` **function**: runs synchronously until its first `await`, and always returns a promise. 2. `await`: suspend the function, return to the caller, and queue the continuation as a microtask when the awaited promise settles. 3. **Microtasks** drain completely after every task and before the next task (timers, I/O, rendering). 4. **Pausing is cheap** because the function becomes a heap-allocated state machine. No thread is blocked. 5. **Concurrency is not parallelism.** `await` overlaps _waiting_, not _computing_. * * * ## Exercises **Puzzle A.** What does this print? ```javascript async function a() { console.log('a1'); await b(); console.log('a2'); } async function b() { console.log('b'); } console.log('start'); a(); new Promise((resolve) => { console.log('p'); resolve(); }).then(() => console.log('then')); console.log('end'); ``` Answer `start, a1, b, p, end, a2, then` `a()` runs synchronously through `a1` and into `b()`, which prints `b` and returns an already-resolved promise. `await` queues the continuation of `a` first. The promise executor prints `p` synchronously and queues `then` second. After `end`, the microtasks run in queue order: `a2`, then `then`. **Puzzle B.** What does this print? ```javascript setTimeout(() => console.log('timeout'), 0); Promise.resolve().then(() => { console.log('micro 1'); Promise.resolve().then(() => console.log('micro 2')); }); queueMicrotask(() => console.log('micro 3')); ``` Answer `micro 1, micro 3, micro 2, timeout` `micro 1` and `micro 3` are queued during the synchronous run. While `micro 1` runs, it queues `micro 2` at the **back** of the queue, behind `micro 3`. The queue is drained completely, including newly added jobs, before the timer task gets a turn.