DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/JavaScript/Async/Await
intermediate20 min read·Updated Jun 2025

Async/Await

async/await is not a new async mechanism — it's syntax that makes promise-based code read like synchronous code. That framing is the key to using it correctly, especially around the single most common performance bug it enables: accidentally serializing independent work.

async-awaitpromisestry-catchparallelfundamentals

Knowledge Check

5 questions · pass at 70%

0/5
mediumpredict output

1. What does this log, and in what order?

code
async function f() {
  console.log('1');
  await null;
  console.log('2');
}
f();
console.log('3');

Interview Questions

2 questions

Cheatsheet

Download-ready reference
Cheatsheet
async function foo() {...}  → ALWAYS returns a Promise
  return x;   → resolves the returned promise with x
  throw e;    → rejects the returned promise with e

await promise
  → pauses ONLY this function, unwraps the resolved value
  → control returns to the CALLER immediately (does not block the world)
  → a rejection THROWS here — catch with try/catch

Everything before the first `await` in an async function runs SYNCHRONOUSLY.
Everything after resumes as a MICROTASK once the current call stack clears.

SEQUENTIAL (slow, only for genuinely dependent steps):
  const a = await fetchA();
  const b = await fetchB(a);   // needs `a` — must be sequential

PARALLEL (fast, for independent steps):
  const [a, b] = await Promise.all([fetchA(), fetchB()]);

useEffect(() => {
  async function load() { ... }   // define + invoke inside — NEVER `async () => {}` directly
  load();
}, []);

Related topics

PromisesFetch APIEvent LoopPro

On this page

20m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

The beginner framing: async/await lets you write asynchronous code that looks like ordinary, top-to-bottom synchronous code — no .then() chains, just await in front of anything that returns a promise.

The precise mental model: async/await is syntax sugar over promises — nothing more, nothing less. An async function always returns a promise, even if you return a plain value inside it (it gets automatically wrapped). await unwraps a promise's resolved value, pausing the async function's execution at that point — but critically, it only pauses that function, not the rest of the program. Control returns immediately to whatever called the async function, exactly like the await'd expression had resolved to a .then() callback (see Event Loop for the underlying microtask mechanism).

async function getUser(id) {
  return { id, name: "Ada" }; // automatically wrapped: returns Promise<{id, name}>
}
 
getUser(1).then((user) => console.log(user.name)); // "Ada" — getUser() IS a promise-returning function

The engine-level view — everything before the first await in an async function runs synchronously, and everything from that await onward resumes as a microtask:

async function example() {
  console.log("A");
  await null;
  console.log("B");
}
 
console.log("start");
example();
console.log("end");
// Output: start, A, end, B
await pauses the function, not the world
step 1 / 6
async function example() {
console.log('A');
await null;
console.log('B');
}
console.log('start');
example();
console.log('end');
Call stack
console.log('start')
Microtask queue
(empty)
Macrotask / callback queue
(empty)
Console output
start

console.log('start') runs first, synchronously, in the outer script.

try/catch replaces .catch()#

async function loadUser(id) {
  try {
    const user = await fetchUser(id);
    const posts = await fetchPosts(user.id);
    return { user, posts };
  } catch (err) {
    console.error("failed to load user:", err);
    throw err; // re-throw if the caller needs to know too
  }
}

A rejected await'd promise throws inside the async function — exactly like a synchronous throw — which is why ordinary try/catch works to catch it, unifying error handling for both sync and async code paths in one construct.


Try It#

This function has a serious, very common performance bug. Find it before checking the solution.

async function loadDashboard(userId) {
  const user = await fetchUser(userId);
  const posts = await fetchPosts(userId);       // does NOT depend on `user`
  const notifications = await fetchNotifications(userId); // does NOT depend on `user` or `posts`
  return { user, posts, notifications };
}
Solution

All three fetches are independent of each other — none needs the previous one's result — yet writing await three times in a row forces them to run sequentially, one completely finishing before the next even starts. If each takes 200ms, this function takes ~600ms total, when it could take ~200ms.

async function loadDashboard(userId) {
  const [user, posts, notifications] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
    fetchNotifications(userId),
  ]);


Implement It Yourself#

Async functions are themselves built on generators + promises under the hood (pre-ES2017, this exact pattern was the common polyfill/library approach). Build a tiny run helper that drives a generator to completion, awaiting each yielded promise — this is essentially "async/await, implemented manually":

function run(generatorFn) {
  const generator = generatorFn();
 
  function step(input) {
    const { value, done } = generator.next(input);
    if (done) return Promise.resolve(value);
    return Promise.resolve(value).then


















This is genuinely close to how Babel used to transpile async/await for older environments before it became native — await really is "yield, but for promises, with the driving loop built into the language itself."


In React#

Event handlers and effects are the two most common places async/await shows up in React. A subtlety worth internalizing: useEffect's callback itself cannot be async (it must return either nothing or a cleanup function — an async function returns a promise instead, which React doesn't know what to do with) — so the pattern is to define an async function inside the effect and call it immediately:

useEffect(() => {
  let cancelled = false;
 
  async function load() {
    const data = await fetchData();
    if (!cancelled) setData(data); // guard against setting state after unmount
  }
 
  load();
 
  return () => { cancelled = true; }; 

The cancelled flag exists because of the exact "await only pauses this function" behavior above — if the component unmounts or the effect re-runs while fetchData() is still pending, the await will still resume and try to call setData on a stale closure, potentially updating state for a component that no longer should be updated.


Common Mistakes#

1. Sequential awaits for independent work (the Try It example)#

Covered above — the single most common async/await performance bug. Always ask: "does this await depend on the previous one's result?" If not, start them together and await with Promise.all.

2. Awaiting inside a loop when the iterations are independent#

const results = [];
for (const id of userIds) {
  results.push(await fetchUser(id)); // one at a time — SLOW for independent fetches
}
const results = await Promise.all(userIds.map(fetchUser)); // all start together

Same root cause as the previous mistake, just in loop form — extremely common in real code because the sequential version often gets written first and "works," just slowly.

3. Forgetting that a non-async function calling an async one doesn't wait for it#

function handleClick() {
  saveData(); // an async function — this call does NOT wait for it to finish
  console.log("saved!"); // logs IMMEDIATELY, likely before saveData() has actually completed
}

Calling an async function without awaiting it (or chaining .then()) fires it off and immediately continues — if handleClick needs to know when saveData finishes, it needs to be async itself and await saveData(), or explicitly handle the returned promise.


Best Practices#

  • Start independent async operations before awaiting any of them, and coordinate with Promise.all/allSettled — never default to sequential await without first asking whether each step actually depends on the previous one.
  • Always wrap awaited code in try/catch (or ensure the caller handles the rejected promise) — an unhandled rejection from an await is just as real a bug as one from a .then() chain.
  • Never make a useEffect callback directly async — define the async logic inside it and invoke it immediately, per the pattern above.
  • (supported in ES modules) for legitimate one-time async setup at module scope, rather than wrapping an entire module in an immediately-invoked async function — but avoid it in code paths that need to stay synchronous for other consumers.

Performance Tips#

  • The performance concern with async/await is essentially always about accidental serialization — the sequential-await mistake above. There is no inherent runtime cost to using async/await versus raw .then() chains; both compile down to the same microtask-based mechanism.
  • Wrapping a value in Promise.resolve() unnecessarily (e.g., await Promise.resolve(x) where x is already a plain value) adds a microtask tick of latency for no benefit — harmless in isolation, but avoid it in genuinely hot paths with many iterations.

return
{ user, posts, notifications };
}

Starting all three promises first (by calling the functions, which begins their work immediately) and only THEN awaiting them together via Promise.all lets them run concurrently — this is the single most common async/await performance mistake in real codebases, and it's invisible unless you're specifically looking for accidentally-sequential independent operations.

(step);
// await the yielded promise, then continue
}
return step();
}
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
run(function* () {
console.log("A");
const x = yield delay(100, "hello"); // behaves just like `await delay(100, "hello")`
console.log("B", x);
const y = yield delay(100, "world");
console.log("C", y);
});
// A
// (100ms later) B hello
// (100ms later) C world
// cleanup: mark stale if the effect re-runs or unmounts
}, []);
Use top-level await