{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"how-async-await-really-works-from-syntax-sugar-to-state-machines-zclcn","url":"https://zyvop.com/how-async-await-really-works-from-syntax-sugar-to-state-machines-zclcn","title":"How async/await Really Works: From Syntax Sugar to State Machines","subtitle":"Explore how JavaScript transforms async/await into resumable state machines using Promises, microtasks, and the event loop.","tldr":"Async/await isn’t magic or parallelism. An async function runs until its first await, then suspends and returns a Promise. When that Promise settles, a microtask resumes execution. Under the hood, this is a resumable state machine.","keywords":["promises","Async","JavaScript","event-loop","internals"],"entities":["Alysaa Lissy","promises","Async","JavaScript","event-loop","internals","ZyVOP"],"keyTakeaways":["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."],"headings":["1. The problem async/await solves","2. Layer 1: the event loop and its two queues","3. Layer 2: promises in 60 seconds","4. Layer 3: what async and await do","Two rules of async functions","What await does","5. What the engine really does: suspend, save, resume","6. Solving the puzzle","7. How many ticks does await cost?","8. Async is not parallel","9. Five pitfalls that follow from the model","Pitfall 1: accidental serial awaits","Pitfall 2: forEach does not wait","Pitfall 3: the floating promise","Pitfall 4: return vs return await inside try","Pitfall 5: microtask starvation","10. The same idea in other languages","11. The mental model on one screen","Exercises"],"outboundLinks":[],"contentText":"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. console.log('1: sync start'); setTimeout(() =&gt; console.log('2: timeout'), 0); (async () =&gt; { console.log('3: inside async, before await'); await null; console.log('4: after await'); })(); Promise.resolve().then(() =&gt; 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: // 2009: callbacks getUser(id, (err, user) =&gt; { if (err) return handle(err); getPosts(user, (err, posts) =&gt; { 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: flowchart TB A[\"&lt;b&gt;async / await&lt;/b&gt;&lt;br/&gt;syntax that pauses and resumes a function\"] B[\"&lt;b&gt;Promises&lt;/b&gt;&lt;br/&gt;an object representing a value that arrives later\"] C[\"&lt;b&gt;Event loop + microtask queue&lt;/b&gt;&lt;br/&gt;decides which callback runs next\"] D[\"&lt;b&gt;Host runtime&lt;/b&gt;&lt;br/&gt;timers, network, disk (browser or libuv)\"] A --&gt;|built on| B B --&gt;|built on| C C --&gt;|built on| DWe 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: flowchart TD A([Start of loop iteration]) --&gt; B[\"Take ONE task from the task queue\"] B --&gt; C[\"Run it until the call stack is empty\"] C --&gt; D{\"Microtask queue empty?\"} D -- No --&gt; E[\"Run the next microtask\"] E --&gt; D D -- Yes --&gt; F[\"Browser only: maybe render a frame\"] F --&gt; AThe 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: stateDiagram-v2 [*] --&gt; Pending Pending --&gt; Fulfilled: resolve(value) Pending --&gt; Rejected: reject(reason) Fulfilled --&gt; [*] Rejected --&gt; [*]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: const p = Promise.resolve(42); p.then((v) =&gt; console.log('then', v)); console.log('sync'); // Output: // sync // then 42Hold on to that. await is built on exactly this behavior. 4. Layer 3: what async and await do Two rules of async functions It always returns a promise. A returned value fulfills it, and a thrown error rejects it. The body runs synchronously until the first await. Nothing is deferred just because the function is marked async. async function demo() { console.log('A'); await somethingAsync(); console.log('B'); } console.log('before'); demo(); console.log('after'); // before // A &lt;- ran synchronously, inside the call to demo() // after // B &lt;- ran later, as a microtask continuationWhat await does When execution reaches await expr, the engine: Evaluates expr and wraps the result in a promise (Promise.resolve(expr)). Suspends the function, saving its local variables and its position. Returns to the caller, which now holds a pending promise and keeps running. Subscribes \"resume me here\" to the awaited promise. When that promise settles, a microtask resumes the function with the result (or throws the error at the await site). sequenceDiagram participant Caller participant Fn as async function participant Net as Network participant MQ as Microtask queue Caller-&gt;&gt;Fn: getUserName(1) Fn-&gt;&gt;Net: fetch() starts Fn--&gt;&gt;Caller: await: suspend and return a pending Promise Note over Caller: Caller keeps running synchronously Net--&gt;&gt;MQ: response arrives, promise fulfills, continuation queued MQ-&gt;&gt;Fn: run continuation Fn-&gt;&gt;Fn: resume right after the await Fn--&gt;&gt;Caller: outer promise fulfills with the resultFor simple, linear code, you can think of await as .then with the rest of the function as the callback: 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) =&gt; res.json()) .then((user) =&gt; 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: Copy its local variables and \"current position\" off the call stack into a heap object. Pop the function off the stack so the caller can continue. 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: function run(generatorFn) { return new Promise((resolve, reject) =&gt; { 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) =&gt; step('next', value), // fulfilled: resume with the value (err) =&gt; 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: 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) =&gt; { 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: stateDiagram-v2 [*] --&gt; S0 S0 : State 0 - start S1 : State 1 - resumed with res S2 : State 2 - resumed with user S0 --&gt; S1: fetch issued, first await S1 --&gt; S2: res.json issued, second await S2 --&gt; [*]: resolve outer promise with user.nameEach 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: async function returnsValue() { return 1; } async function returnsPromise() { return Promise.resolve(1); } returnsPromise().then(() =&gt; console.log('returnsPromise')); returnsValue().then(() =&gt; console.log('returnsValue')); // Output: // returnsValue &lt;- called SECOND, but finishes first // returnsPromiseYou 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: setTimeout(() =&gt; console.log('timer fired'), 100); async function crunch() { const start = Date.now(); while (Date.now() - start &lt; 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 mssequenceDiagram participant T as Timer 100 ms participant L as Event loop participant J as Your JS L-&gt;&gt;J: run task: heavy loop (800 ms) Note over J: Nothing else can run. No timers, no promises, no clicks. T--&gt;&gt;L: 100 ms elapsed, callback queued J--&gt;&gt;L: loop finishes after 800 ms L-&gt;&gt;J: run timer callback, 700 ms lateFor 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 // 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));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-&gt;&gt;A: request and App-&gt;&gt;B: request and App-&gt;&gt;C: request end A--&gt;&gt;App: response B--&gt;&gt;App: response C--&gt;&gt;App: responseUnbounded 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 items.forEach(async (item) =&gt; { 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); // sequentialforEach ignores the promises returned by your callback, so there is nothing for it to wait on. Pitfall 3: the floating promise 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 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: function loop() { Promise.resolve().then(loop); } loop(); // timers, clicks and rendering never get a turn10. 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: sequenceDiagram participant Ex as Executor participant F as Future (state machine) participant IO as OS reactor Ex-&gt;&gt;F: poll() F-&gt;&gt;IO: register interest and a waker F--&gt;&gt;Ex: Poll::Pending Note over Ex: Executor runs other tasks meanwhile IO--&gt;&gt;Ex: waker.wake() Ex-&gt;&gt;F: poll() again F--&gt;&gt;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 async function: runs synchronously until its first await, and always returns a promise. await: suspend the function, return to the caller, and queue the continuation as a microtask when the awaited promise settles. Microtasks drain completely after every task and before the next task (timers, I/O, rendering). Pausing is cheap because the function becomes a heap-allocated state machine. No thread is blocked. Concurrency is not parallelism. await overlaps waiting, not computing. Exercises Puzzle A. What does this print? async function a() { console.log('a1'); await b(); console.log('a2'); } async function b() { console.log('b'); } console.log('start'); a(); new Promise((resolve) =&gt; { console.log('p'); resolve(); }).then(() =&gt; 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? setTimeout(() =&gt; console.log('timeout'), 0); Promise.resolve().then(() =&gt; { console.log('micro 1'); Promise.resolve().then(() =&gt; console.log('micro 2')); }); queueMicrotask(() =&gt; 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.","contentHash":"sha256:0a3646b432a0e0324acf673965451894a583ec22e8123d4fb279c13add346155","authorName":"Alysaa Lissy","authorUrl":"https://zyvop.com/author/alysaa","authorSameAs":[],"category":null,"tags":["promises","Async","JavaScript","event-loop","internals"],"audience":"Software engineers and developers building applications with promises","tone":"Instructional, practical, code-first","readingTimeMinutes":11,"wordCount":2903,"faqs":null,"primaryTopic":"promises","publishedAt":"2026-11-10T10:38:03.786Z","updatedAt":"2026-11-10T10:38:03.786Z","canonicalUrl":"https://zyvop.com/how-async-await-really-works-from-syntax-sugar-to-state-machines-zclcn"}