TechEarl

How async Functions Really Work in JavaScript

What an async function actually is under the hood: it desugars to a generator plus a built-in runner. Plus async generators with for await...of, the AsyncFunction constructor, and why detecting async-ness is a trap.

Ishan Karunaratne⏱️ 8 min readUpdated
Share thisCopied
How async functions work in JavaScript: they desugar to a generator plus a runner. Plus async generators with for await...of, the AsyncFunction constructor, and why you should not detect async-ness.

An async function in JavaScript is a generator with a runner bolted on. That single sentence explains almost everything that confuses people about async/await: why an async function always returns a promise, why await looks like it pauses execution, and why async/await arrived years after generators did. The keyword is sugar over a pattern people were already hand-rolling.

This is the internals page, not the how-to. If you want the practical guide to states, chaining, error handling, and the sequential-vs-parallel await trap, read the complete guide to JavaScript promises first. What follows is what the engine is doing underneath, and the two places where knowing it actually changes the code you write: async generators, and dynamic codegen.

What async/await desugars to

Before async/await (ES2017), people drove asynchronous flows with generators plus a runner. A generator can pause at every yield and resume later with a value handed back in. So you yield a promise, and a wrapper function waits for that promise to resolve, then resumes the generator with the resolved value. Kyle Simpson and others taught this pattern heavily around 2014, and libraries like co packaged the runner.

Here is the pattern, reduced to its essentials:

javascript
// A runner: feed it a generator that yields promises, get back a promise.
function teRunAsync(genFn) {
  return new Promise((resolve, reject) => {
    const gen = genFn();
    function step(method, arg) {
      let result;
      try {
        result = gen[method](arg); // .next(value) or .throw(error)
      } catch (err) {
        return reject(err);
      }
      if (result.done) return resolve(result.value);
      // Wait for the yielded promise, then resume the generator.
      Promise.resolve(result.value).then(
        (value) => step("next", value),
        (error) => step("throw", error)
      );
    }
    step("next");
  });
}

// Usage: yield where you would later write await.
teRunAsync(function* () {
  const user = yield fetch("/api/me").then((r) => r.json());
  const posts = yield fetch(`/api/posts?u=${user.id}`).then((r) => r.json());
  return posts.length;
}).then((count) => console.log(count));

Now compare it to the modern version:

javascript
async function teCountPosts() {
  const user = await fetch("/api/me").then((r) => r.json());
  const posts = await fetch(`/api/posts?u=${user.id}`).then((r) => r.json());
  return posts.length;
}

They are the same machine. await is yield, async supplies the runner, and the engine does it natively instead of you shipping co. Two consequences fall straight out of this model. First, an async function always returns a promise, because the runner driving it produces one. A bare return 42 resolves to 42; a throw rejects. Second, await does not block the thread. It hands control back to the event loop (the generator suspends) and resumes when the awaited promise settles. The function looks synchronous; the engine is still cooperatively interleaving.

Async generators and for await...of

This is the part worth keeping in your head, because it is the one place the generator heritage shows up as a feature you reach for rather than trivia. An async generator (async function*) can both await and yield. You get a stream you consume with for await...of. It has been in the language since ES2018, supported in Node.js 10 (2018) and every evergreen browser since early 2020.

The natural fit is anything that arrives in pieces over time: a paginated API, a log tail, a server-sent stream. You write the "go fetch the next page" logic once, yield each item, and the caller iterates as if it were a plain array:

javascript
async function* teFetchPages(url) {
  let next = url;
  while (next) {
    const res = await fetch(next);
    const page = await res.json();
    for (const item of page.items) {
      yield item; // hand one record to the consumer
    }
    next = page.nextPage; // null when there are no more pages
  }
}

// The consumer never sees the pagination. It just iterates.
for await (const item of teFetchPages("/api/items?page=1")) {
  console.log(item.id);
}

The pagination, the awaits, and the loop all stay inside the generator. The caller gets a clean for await...of and back-pressure for free: the next page is not requested until the consumer asks for the next item. This is exactly how you consume a streamed fetch body line by line, which I cover in streaming a fetch response with ReadableStream: response.body is itself an async iterable.

The AsyncFunction constructor (dynamic async code)

AsyncFunction is not a global like Function. You reach it through the prototype of any async function:

javascript
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;

const fn = new AsyncFunction(
  "ms",
  "await new Promise((r) => setTimeout(r, ms)); return 'done';"
);

await fn(50); // "done"

This is the async sibling of new Function(...): it builds an async function from a string at runtime. The honest use case is narrow, dynamic codegen where the body is genuinely not known until runtime (a rules engine, a sandboxed expression evaluator you own). It carries the usual new Function costs: the body is parsed when the function is created rather than with the surrounding script, so it is slower, it does not close over local scope, and feeding it untrusted strings is eval by another name. Reach for it only when you actually need to compile code on the fly. For everything else, write a normal async function.

Detecting an async function (and why you usually should not)

You can ask whether a function is async:

javascript
function teIsAsync(fn) {
  return fn?.constructor?.name === "AsyncFunction";
}

It works in a plain modern engine. It is also brittle and, more importantly, usually pointless. Two reasons not to lean on it:

It breaks under transpilation and bundling. Babel, esbuild, and TypeScript's older targets compile async/await down to generators or state machines, at which point the constructor name is no longer "AsyncFunction", and your check silently returns false on code that is async in every way that matters.

You almost never need the answer. An async function and an ordinary function that returns a promise are interchangeable from the caller's side, and await works on non-promise values too (it wraps them in Promise.resolve). So you do not branch on async-ness, you just await the result and let the language normalize it:

javascript
// Works whether handler is async, returns a promise, or returns a plain value.
async function teRun(handler, input) {
  const result = await handler(input);
  return result;
}

There are real edge cases (a decorator that needs to behave differently for async methods, tooling that introspects a callback-heavy codebase mid-migration), but they are the exception. If you find yourself detecting async-ness in normal application code, the cleaner fix is almost always to await and stop caring.

See also

Sources

Authoritative references this article was fact-checked against.

Tagsasync functionasync generatorsfor await ofAsyncFunctiongeneratorsJavaScriptpromises

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Merge Two Arrays in JavaScript

Merge two arrays in JavaScript three ways: copy-merge with spread [...a, ...b], merge in place with a.push(...b), or copy with a.concat(b). Which to pick by mutation, memory, and the spread arg-count limit that bites on very large arrays.

How to sleep(), wait, and poll in JavaScript

JavaScript has no blocking sleep(). Here is the one-liner that actually works (await a Promise around setTimeout), Node's native timers/promises, a cancellable polling helper, and the setTimeout 4ms-clamp and Date.now vs performance.now gotchas.