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 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();
}, []);
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 functionalways returns a promise, even if you return a plain value inside it (it gets automatically wrapped). awaitunwraps 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.
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.
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 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."
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.
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.
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.
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