How Async/Await Functions Work Under the Hood in JavaScript

Async/await functions are syntactic sugar over Promises that compile to state machines, automatically wrapping return values in Promises and pausing execution at each await until the awaited expression settles.

The leonardomso/33-js-concepts repository provides an exhaustive test suite in tests/functions-execution/async-await/async-await.test.js that demonstrates exactly how the JavaScript engine transforms async function declarations into Promise-based execution flows. Understanding this transformation is essential for mastering modern asynchronous programming patterns.

The Promise Foundation

async/await does not introduce new concurrency primitives to JavaScript. Instead, it provides a synchronous-looking syntax for code that ultimately operates on Promises. When the JavaScript parser encounters an async function, it rewrites the body into a state machine that chains Promise callbacks.

As implemented in the 33-js-concepts test suite, every async function exhibits five core behaviors that reveal its Promise-based nature: automatic Promise wrapping, await pausing, error transformation, avoidance of double-wrapping, and thenable support.

Automatic Promise Wrapping

An async function always returns a Promise, regardless of what you explicitly return. The engine automatically wraps the return value using Promise.resolve().

The test "should make a function return a Promise" in async-await.test.js:10-19 demonstrates this behavior. Even when you return a primitive, the caller receives a Promise:

async function fetchNumber() {
  return 42;  // Compiled to: return Promise.resolve(42);
}

fetchNumber().then(v => console.log(v));  // → 42

This automatic wrapping ensures that async functions maintain a consistent contract: they always return thenable objects that can be chained with .then() and .catch().

The Await Pause Mechanism

The await keyword triggers the state machine to pause execution until the awaited expression settles. Critically, the test "code before await is synchronous" in async-await.test.js:77-100 proves that code before the first await runs immediately, while subsequent code queues as a micro-task.

Under the hood, the engine compiles the function into a chain of .then() callbacks:

async function foo() {
  // Compiled approximately to:
  return Promise.resolve()
    .then(() => expr1)          // Evaluate before first await
    .then(val => { /* resume */ })
    .then(() => expr2);         // Second await, etc.
}

This compilation strategy keeps the call stack shallow while maintaining the illusion of sequential execution:

async function demoOrder() {
  console.log('before');        // Runs immediately (synchronous)
  await Promise.resolve();      // Creates micro-task pause
  console.log('after');         // Runs after current tick
}

demoOrder();  // Logs "before", then "after" in next micro-task

Error Handling Transformation

async functions transform synchronous throws into rejected Promises. If an async function throws an exception, the engine catches it internally and rejects the returned Promise with the thrown value.

The test "should convert thrown errors to rejected Promises" in async-await.test.js:32-40 verifies this behavior:

async function mayFail(flag) {
  if (flag) throw new Error('boom');
  return 'ok';
}

mayFail(true).catch(e => console.error(e.message));  // → "boom"

This design eliminates the distinction between synchronous failures and asynchronous rejections, allowing a single .catch() handler to manage all error scenarios.

Thenable Support and Avoiding Double-Wrapping

The engine optimizes for cases where you explicitly return a Promise. The test "should not double-wrap returned Promises" in async-await.test.js:41-52 confirms that Promise.resolve(42) returned from an async function stays a single Promise rather than becoming Promise.resolve(Promise.resolve(42)).

Additionally, await works with any thenable—objects implementing a then method. The test "should work with thenable objects" in async-await.test.js:25-38 demonstrates that custom thenables behave like native Promises:

const customThenable = {
  then(resolve) { resolve(42); }
};

async function consumeThenable() {
  const value = await customThenable;  // Calls .then()
  return value;
}

Sequential vs. Parallel Execution Patterns

Understanding the compilation model reveals why await inside loops creates sequential execution while Promise.all enables parallelism. The 33-js-concepts repository tests both patterns in "Sequential vs Parallel Execution" (async-await.test.js:71-124).

Sequential execution waits for each Promise to settle before starting the next:

async function sequential() {
  const a = await delay(100, 'A');  // Wait 100ms
  const b = await delay(100, 'B');  // Wait another 100ms
  return [a, b];                    // Total: 200ms
}

Parallel execution starts all operations immediately and awaits the aggregated Promise:

async function parallel() {
  const [a, b] = await Promise.all([
    delay(100, 'A'),  // Starts immediately
    delay(100, 'B')   // Starts immediately
  ]);
  return [a, b];       // Total: ~100ms
}

Interoperability with Promise Chains

async/await compiles to standard Promise chains, enabling seamless mixing of syntax styles. The test "should allow mixing async/await and Promise chains" in async-await.test.js:71-89 demonstrates this interoperability:

async function step1() { return 1; }
function step2(v) { return Promise.resolve(v + 1); }
async function step3(v) { return v + 1; }

const result = await step1()
  .then(step2)   // Returns Promise
  .then(step3);  // Async function, returns Promise
// result === 3

Summary

  • Syntactic sugar: async/await compiles to Promise-based state machines, not new concurrency primitives.
  • Always Promise: async functions automatically wrap return values with Promise.resolve() according to async-await.test.js.
  • Pause points: Code before the first await runs synchronously; subsequent code queues as micro-tasks.
  • Error normalization: Thrown exceptions become rejected Promises, unifying error handling between sync and async code.
  • Thenable flexibility: await accepts any object with a then method, not just native Promises.
  • Execution control: Sequential await calls chain sequentially; Promise.all enables parallel execution within async functions.

Frequently Asked Questions

Does async/await replace Promises in JavaScript?

No, async/await is syntactic sugar built on top of Promises. According to the 33-js-concepts source code, the JavaScript engine rewrites async functions into state machines that chain .then() callbacks. You cannot use await without a Promise-based runtime, and async functions always return Promise objects.

Why does code before the first await run immediately?

The JavaScript engine evaluates the expression before the first await as part of the synchronous function entry. As demonstrated in async-await.test.js:77-100, only the code after the await keyword gets deferred to the micro-task queue. This design allows async functions to perform synchronous setup before yielding control to the event loop.

What happens if I throw an error inside an async function?

The JavaScript engine automatically catches synchronous throws and converts them into rejected Promises. When you throw new Error() inside an async function, the returned Promise enters the rejected state, allowing callers to handle the error using .catch() or try/catch blocks in parent async functions.

Can I await non-Promise values?

Yes. The await operator accepts any thenable—objects with a then method—or non-thenable values. According to async-await.test.js:25-38, if you await a regular value like 42, the engine wraps it in Promise.resolve(42) and resolves immediately. If you await a custom thenable, the engine invokes its then method to retrieve the resolution value.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →