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 functionThe 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, Basync function example() {console.log('A');await null;console.log('B');}console.log('start');example();console.log('end');
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),
]);
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.
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(step); // await the yielded promise, then continue
}
return step();
}
function delay(ms, value) {
return new Promise((resolve)
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; }; // cleanup: mark stale if the effect re-runs or unmounts
}, []);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 togetherSame 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 sequentialawaitwithout 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 anawaitis just as real a bug as one from a.then()chain. - Never make a
useEffectcallback directlyasync, 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/awaitversus 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)wherexis 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.
