01The Picture You Need
JavaScript runs on a single thread. One thing at a time, always. That constraint sounds crippling until you understand how the runtime works around it: while your code is waiting on something slow — a network response, a timer, a file read — the engine parks that work, hands control back to whatever's next in the queue, and returns to your waiting code only when the slow thing is done. That's the event loop, and everything about async/await makes sense the moment you internalize it.
A Promise is the primitive that makes this possible. When you call fetch(), you don't get data — you get a Promise: a placeholder that represents a future value, already in one of three states: pending, fulfilled, or rejected. The fetch itself is handed off to the browser's networking layer, which runs outside the JS thread entirely. Your code keeps moving. When the network responds, a callback is queued in what the spec calls the microtask queue — a high-priority lane that drains completely before the engine picks up the next regular event. This ordering matters more than most tutorials admit.
The fetch itself is handed off to the browser's networking layer, which runs outside the JS thread entirely.
02What await Actually Does
async/await is syntax sugar over Promises — but understanding what that sugar desugars to is the whole game.
async function getUser(id) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data;
}Every await expression suspends the current function and yields control back to the event loop. The function doesn't block; it pauses itself, parks a continuation (the rest of the function body after the await), and lets other work run. When the awaited Promise settles, the continuation is queued as a microtask and the function resumes from exactly where it stopped.
This is the mental model that cures most async confusion: an async function is a Promise that pauses, resumes, and eventually resolves. The caller doesn't wait either — calling getUser(42) returns a Promise immediately. If the caller wants the value, it needs its own await, which is why async tends to propagate upward through a call stack.
The common trap falls out of this directly. Consider:
const ids = [1, 2, 3];
ids.forEach(async (id) => {
const user = await getUser(id);
console.log(user.name);
});
console.log('done');'done' logs first, every time. forEach fires each callback and moves on — it has no idea the callbacks are async, and it doesn't await them. If you need sequential execution, a for...of loop with await inside is the right tool. If you want parallel requests, Promise.all is explicit and honest about the intent.
03Errors and the Microtask Timing Edge
Unhandled rejections bite async code harder than synchronous errors, because the failure surface is invisible. A try/catch inside an async function catches rejected Promises exactly as it catches thrown errors — that symmetry is one of async/await's genuine wins.
async function safeFetch(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Fetch failed:', err.message);
return null;
}
}What catches developers off-guard is when errors surface. Because rejections travel through the microtask queue, an unhandled rejection won't throw synchronously — it appears later, sometimes after surrounding code has already run. Modern runtimes (browsers and Node.js alike) emit an unhandledrejection event for these, and will increasingly treat them as fatal. Always either await a Promise or explicitly chain .catch() onto it; never fire-and-forget a Promise you care about.
The microtask queue also explains a subtler puzzle: even a Promise.resolve() — an already-settled Promise — doesn't resume synchronously. Its continuation still queues as a microtask, so code after the await always runs later, as a microtask, after the current synchronous code finishes. Zero-delay doesn't mean no delay.
