skip to content

Rewrite this async function as an equivalent promise chain and say what the rewrite reveals about `await`: `async function load(id) { const user = await fetchUser(id); const posts = await fetchPosts(user.id); return { user, posts }; }`

level: middleimportance: must knowfreq 70%

answer

  1. the function is scissored at each pause
  2. the tail becomes somebody's callback
  3. scope is what the sugar really buys
  4. the flat version must carry values manually
  5. one frame, not several callbacks

basics

~10 s

Each await splits the function in two: the awaited expression becomes the promise you attach to, and everything after it becomes the callback. So load becomes fetchUser(id).then(user => fetchPosts(user.id).then(posts => ({ user, posts }))).

solid answer

~40 s

Written as a chain: `function load(id) { return fetchUser(id).then(user => fetchPosts(user.id).then(posts => ({ user, posts }))); }`. The rewrite shows that `await X` means "resolve `X`, then run the rest of this function as its continuation", and that the final `return` value flows into the outer promise's resolution the same way a `.then` callback's return value does. Nesting appears because `user` has to stay in scope for the second step — a flat chain would have to thread it manually. It is a faithful *observational* model, not what the engine literally does: async functions suspend and resume one frame, so locals, `this`, `try`/`finally` blocks spanning awaits and loops all survive, which a fixed `.then` chain cannot express — a loop with `await` in it desugars to recursion, not a chain.

code

javascript · 18 lines
javascript
const fetchUser = id => Promise.resolve({ id, name: 'Ada' });
const fetchPosts = userId => Promise.resolve([{ userId, title: 'Notes' }]);

async function loadAsync(id) {
  const user = await fetchUser(id);
  const posts = await fetchPosts(user.id);
  return { user, posts };
}

function loadChained(id) {
  return fetchUser(id).then(user =>
    fetchPosts(user.id).then(posts => ({ user, posts }))
  );
}

Promise.all([loadAsync(1), loadChained(1)]).then(([a, b]) => {
  console.log(JSON.stringify(a) === JSON.stringify(b)); // true
});

go deeper

for a junior

Practise writing the chain by hand for a two-step function, and be able to point at which part of the original becomes the .then callback and which value becomes its parameter.

for a middle

Explain the transform precisely: resolve the awaited expression, run the rest of the function as its continuation, and let the final return flow through promise resolution. Show why the naive chain has to nest.

for a senior

Demonstrate where the model breaks — loops, try/finally spanning a suspension, stack traces — and make the point that the engine suspends one frame rather than building callbacks, which is why refactoring between the two forms is not always mechanical.

for a principal

Frame it as a readability and maintenance argument: the sugar preserves ordinary control flow and scope, so error paths and cleanup stay reviewable. Be ready to say when a chain is still the clearer expression of a pipeline.

## The mechanical rewrite ```js function load(id) { return fetchUser(id).then(user => fetchPosts(user.id).then(posts => ({ user, posts })) ); } ``` A more literal transform also guards the synchronous prefix, because an async function turns a synchronous throw into a rejection: ```js function load(id) { try { return fetchUser(id).then(/* ... as above ... */); } catch (e) { return Promise.reject(e); // what `async` does for free } } ``` ## What each piece of the sugar maps to - **`async` on the declaration** → the function is guaranteed to return a promise, and any synchronous throw becomes a rejection of it. - **`await X`** → `Promise.resolve(X).then(continuation)`. `X` is resolved (so a non-promise or a thenable both work), and *everything textually after the await* becomes the continuation. - **the value of the `await` expression** → the parameter of that continuation callback. - **`return value`** → the value returned from the innermost callback, which the surrounding chain resolves with — including thenable adoption, so returning a promise does not nest. The single most useful sentence to say out loud is: **await is a cut point.** The function is scissored at each `await`, and the tail becomes a callback. ## Why the naive rewrite nests In the async version, `user` is an ordinary local variable that is still in scope on the last line. In a chain, each `.then` callback is a separate function with its own scope, so a later step can only see an earlier value if it is lexically inside that step's callback, or if you thread it through explicitly: ```js // flat, but you must carry the value yourself function load(id) { let user; return fetchUser(id) .then(u => { user = u; return fetchPosts(u.id); }) .then(posts => ({ user, posts })); } ``` That carrying — a mutable outer variable or an ever-growing tuple passed down the chain — is precisely the ceremony `async`/`await` removes, and it is the answer to "why bother with await if it is just `.then`". ## Where the model stops being literal The chain is observationally equivalent for straight-line code, but the engine does not rewrite your function into callbacks. It compiles it into a resumable frame — a state machine that remembers where it stopped. That difference is visible in several places: - **Control flow spanning awaits.** `try { const a = await p; } finally { cleanup(); }` keeps one lexical block across the suspension. A callback rewrite has to re-create that with `.finally` and careful reordering. - **Loops.** `for (const id of ids) { results.push(await fetchUser(id)); }` has no fixed-length chain equivalent, because the number of steps is not known statically. Hand-desugaring needs recursion or a `reduce` over a promise accumulator. - **Locals, `this`, and closures.** All survive the suspension unchanged, because it is one frame, not several separate callback scopes. - **Stack traces.** Engines reconstruct an async stack across suspension points; a hand-written chain gives you the callback's stack and usually less context. - **Scheduling detail.** The exact number of queued steps between the awaited value settling and the function resuming is not identical between the two forms. That is a question about job scheduling, not about the sugar, and it is why you should describe the rewrite as *equivalent in outcome*. ## How to answer this in an interview Write the chain, then say the three things it proves: the returned promise is created by the wrapper, not by the body; `await` is resolve-plus-continuation; and the return value goes through promise resolution, which is why returning a promise flattens. Then volunteer the limit of the model — loops and `try`/`finally` — because that is the part that shows you know it is a state machine and not a macro.

  • Why can a `for` loop containing an `await` not be expressed as a fixed `.then` chain?
    Because the number of steps is not known statically — it depends on the data at runtime. A chain is built at parse time with a fixed shape, so you need recursion or a `reduce` that folds each iteration onto a promise accumulator. The state-machine transform an engine actually uses has no such restriction: it just re-enters the same frame.
  • If the two forms are equivalent, what does the async version give you that the chain does not?
    A single lexical scope across the whole operation. Locals stay in scope after a suspension, `this` is unchanged, and ordinary control flow — loops, `if`, `try`/`finally` blocks spanning a suspension — works normally. The chain has to simulate all of that with nesting, carried variables, or extra handlers.
  • Where does the synchronous code before the first `await` end up in the rewrite?
    It stays synchronous — it runs when the function is called, before any promise is attached to. The only extra work the sugar does for it is wrapping it so that a throw becomes a rejection of the returned promise rather than a synchronous exception, which the hand-written version needs an explicit try/catch to reproduce.

saying these in an interview costs you the question

  • Says await is literally compiled into .then callbacks
  • Forgets the code after await becomes the continuation
  • Writes a flat chain that loses the earlier variable
  • Claims a loop with await maps to a fixed chain
  • Thinks the rewrite must wrap the return value again

context