Promises & async
What a promise is
A promise represents a value that isn't ready yet. It's in exactly one of three states: pending, then either fulfilled (with a value) or rejected (with an error). Once settled, a promise never changes state: extra resolve()/reject() calls are silently ignored, and handlers attached after settlement still run with the stored result.
const data = fetch("/api/user"); // a promise, immediately
data.then((res) => console.log(res)) // runs later, on success
.catch((err) => console.error(err)) // runs on failure anywhere above
.finally(() => hideSpinner()); // runs either way
How async/await maps to promises
An async function always returns a promise; whatever you return gets wrapped. await pauses the function (not the page) until a promise settles, then hands you the value. If the promise rejects, await throws, which is why try/catch works:
async function loadUser() {
try {
const res = await fetch("/api/user");
return await res.json();
} catch (err) {
console.error("failed:", err);
}
}
A common wrong claim: "await blocks the browser." It suspends only the async function; everything else (clicks, rendering, other code) keeps running.
Why promises exist
Before promises, async code meant callbacks nested inside callbacks ("callback hell"), and every layer needed its own error handling. Promises flatten the steps into a chain where a single .catch covers everything above it, and you can attach as many handlers as you want, whenever you want.
To make your own promise, for example when wrapping setTimeout:
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await delay(500); // half a second later
The function you pass to new Promise runs immediately; you call resolve(value) or reject(error) when the work is done. Getting a value out again always goes through .then or await; there is no synchronous way to unwrap a promise.
Every .then returns a new promise
The load-bearing rule of chaining: .then() doesn't return the same promise. It returns a new one, and what your handler does decides its fate: return a value and the new promise fulfills with it; throw and it rejects; return another promise and the chain waits for that promise to settle. .catch(fn) is literally .then(undefined, fn), so a rejection skips fulfillment handlers until it meets a rejection handler, and a .catch that returns normally recovers the chain back to the success track.
The floating-promise bug
Forget one return and the chain stops waiting:
getUrl()
.then((url) => { fetch(url); }) // ❌ fetch's promise is floating
.then((data) => console.log(data)); // undefined — didn't wait, and
// fetch errors escape the chain
One missing keyword produces race conditions and unhandled rejections at once. The same trap exists in async form: calling an async function without await (or void/.catch) is fire-and-forget.
Promise.all vs allSettled vs race vs any
Promise.allis fail-fast: one rejection rejects everything, and results keep input order.Promise.allSettlednever rejects; you get{status, value|reason}per input. For "do what you can" batches.Promise.race: the first promise to settle wins, success or failure. The timeout pattern.Promise.any: the first to fulfill wins, and only if all fail do you get anAggregateError. Fallback mirrors.
Interviewers asking "what's the difference" are checking whether you pick a combinator by what failure should mean rather than by habit.
Sequential awaits are a performance bug
const a = await fetchA(); // ❌ ~sum of latencies
const b = await fetchB();
const [x, y] = await Promise.all([fetchA(), fetchB()]); // ✓ ~max latency
If the operations are independent, start them first, then await them together. An await inside a for loop serializes the whole batch. Sometimes serialization is what you want (rate limits); usually it isn't.
A few more facts at this level: await accepts any thenable, top-level await works in ES modules, and an API that can return a promise should do so on every call, even when the result happens to be available synchronously.
Why promise handlers never run synchronously
Promise handlers run on the microtask queue, never synchronously, even if the promise is already settled when you attach them. This is a deliberate design guarantee, known in the community as the "Zalgo" rule: consumers can rely on .then(fn) never running fn before the current code finishes. Every await is a suspension point that yields to the microtask queue, so state you checked before an await may have changed by the time the function resumes: the component unmounted, the user navigated, a newer request finished first. Re-validate after resuming; that habit removes a whole class of race bugs.
What happens to unhandled rejections
A rejected promise with no rejection handler isn't silent: browsers fire unhandledrejection (and rejectionhandled if a handler shows up late), and Node can crash the process. Fire-and-forget async calls are a production risk, not a style choice. Either await them or give them an explicit .catch, and wire unhandledrejection into your error reporting. Two more rules in the same territory: a throw inside an executor or async function becomes a rejection, and an error thrown in .finally replaces whatever result was flowing through.
Cancellation is cooperative
Promises have no .cancel(); a pending promise cannot be killed from outside. The platform pattern is AbortController:
const ctrl = new AbortController();
fetch(`/api/search?q=${q}`, { signal: ctrl.signal })
.catch((e) => { if (e.name !== "AbortError") throw e; });
// user typed again:
ctrl.abort();
Know the toolkit around it: AbortSignal.timeout(ms) for deadline-shaped cancellation, AbortSignal.any([...]) to combine reasons, and accepting a signal parameter in your own async APIs so cancellation composes. Distinguishing AbortError from real failures is the difference between a clean cancel and a phantom error toast.
Where this bites in real UIs
Promise.race for timeouts has a leak: the losing operation keeps running, because race only ignores its result. Same with any. If the loser holds a connection or CPU, cancel it explicitly. The canonical interview scenario is autocomplete: a request per keystroke, responses arriving out of order, stale data overwriting fresh. The fix is latest-wins: abort the previous request on each keystroke, or version-token the responses and drop stale ones. The same failure-semantics choice decides all vs allSettled for a dashboard of independent widgets: tolerant allSettled keeps one dead endpoint from blanking every widget, where fail-fast all would.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.