The Mental Model

async on a function means "this function returns a promise". await means "pause this function until the promise resolves, then give me the resolved value". The rest of the program keeps running while you wait. That's the entire model — everything else is patterns built on it.

Sequential vs Parallel — The Most Common Bug

// SEQUENTIAL — slow. Each user fetched after the previous completes.
const results = [];
for (const id of userIds) { results.push(await fetchUser(id)); // waits for each one
} // PARALLEL — fast. All fetches fire at once.
const results = await Promise.all(userIds.map(id => fetchUser(id)));

For 50 fetches taking 200ms each: sequential takes 10 seconds, parallel takes 200ms. This single pattern is the highest-impact async optimization in most codebases.

When sequential is correct: when each call depends on the previous result, or when you're rate-limited and parallel calls would get throttled.

Error Handling

async function loadUser(id) { try { const res = await fetch(`/users/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err) { console.error("Failed to load user:", err); throw err; // re-throw so callers know }
}

Three rules: (1) wrap awaits in try/catch when you want to handle the error locally, (2) check res.ok because fetch doesn't throw on 4xx/5xx, (3) re-throw if you logged but can't actually recover.

Promise.all vs Promise.allSettled

Promise.all rejects as soon as any promise rejects — and you lose the results from the others. Promise.allSettled waits for all and returns each result individually:

const results = await Promise.allSettled(userIds.map(id => fetchUser(id))); const succeeded = results.filter(r => r.status === "fulfilled").map(r => r.value);
const failed = results.filter(r => r.status === "rejected").map(r => r.reason); console.log(`Loaded ${succeeded.length} users, ${failed.length} failed`);

Use allSettled when partial success is acceptable. Use all when one failure means the whole operation should abort.

Timeouts

function withTimeout(promise, ms) { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), ms) ) ]);
} const data = await withTimeout(fetch("/api/slow"), 3000);

Promise.race resolves or rejects with whichever promise settles first. Combined with a setTimeout-based reject, you get a clean timeout. Note: this doesn't actually cancel the underlying fetch — it just stops waiting. For real cancellation, use AbortController.

Cancellation with AbortController

const controller = new AbortController(); // Cancel after 3 seconds
setTimeout(() => controller.abort(), 3000); try { const res = await fetch("/api/data", { signal: controller.signal }); const data = await res.json();
} catch (err) { if (err.name === "AbortError") { console.log("Request was cancelled"); } else { throw err; }
}

AbortController actually cancels the network request, releases the connection, and fires the AbortError. Use it for user-initiated cancels (clicked away from page) and as the right way to implement timeouts on fetch.

Retry with Backoff

async function withRetry(fn, attempts = 3, delayMs = 1000) { for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { if (i === attempts - 1) throw err; await new Promise(r => setTimeout(r, delayMs * Math.pow(2, i))); } }
} const data = await withRetry(() => fetch("/api/flaky").then(r => r.json()));

Exponential backoff — wait 1s, 2s, 4s — is what every production HTTP client does. Add jitter (random 0-500ms) if many clients might retry simultaneously.

The Forgotten Await

async function save(data) { return db.write(data); // OK if you just want to return the promise
} async function saveAndLog(data) { db.write(data); // BUG — fires off, doesn't wait, errors silently swallowed console.log("Saved!"); // Lies — actually fires before write completes
} async function saveAndLogFixed(data) { await db.write(data); console.log("Saved!");
}

Forgetting await is the #1 silent async bug. The function returns immediately, the promise dangles, errors disappear into the unhandled-rejection void. TypeScript catches some of these with no-floating-promises; ESLint's require-await catches others. Linters are worth their weight in gold here.

Test These Patterns

These patterns become muscle memory only by writing them. Open the online JavaScript editor, paste any of the snippets above, and modify the timing to see the difference between sequential and parallel for yourself. For debugging async flows, the techniques in the JavaScript debugging guide apply directly — async stack traces are how you actually understand promise chains.