Async JavaScript trips up even experienced developers. Here's a reference for the patterns that actually matter in day-to-day work.
The Basics: async/await
async/await is syntactic sugar over Promises. Under the hood, it's the same — but it reads like synchronous code.
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
return data;
}
Always handle errors. Unhandled promise rejections will crash Node processes and silently fail in browsers.
async function fetchUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Failed to fetch user:', err);
throw err; // re-throw so the caller knows
}
}
Running Things in Parallel
The most common async mistake: needlessly sequential awaits.
// ❌ Sequential — takes 600ms total
const user = await fetchUser(id); // 200ms
const posts = await fetchPosts(id); // 200ms
const friends = await fetchFriends(id); // 200ms
// ✅ Parallel — takes 200ms total
const [user, posts, friends] = await Promise.all([
fetchUser(id),
fetchPosts(id),
fetchFriends(id),
]);
Use Promise.all whenever the requests are independent of each other.
Promise Combinators
The four combinators — each for a different use case:
| Method | Resolves when | Rejects when |
|---|---|---|
Promise.all |
All resolve | Any rejects |
Promise.allSettled |
All settle (resolve or reject) | Never |
Promise.race |
First settles | First settles (with rejection) |
Promise.any |
First resolves | All reject |
// Use allSettled when you want all results, even partial failures
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(2),
fetchUser(999), // might 404
]);
for (const result of results) {
if (result.status === 'fulfilled') {
console.log(result.value);
} else {
console.warn('Failed:', result.reason);
}
}
// Use any for "first success wins" — e.g. redundant endpoints
const data = await Promise.any([
fetch('https://primary.api.com/data'),
fetch('https://fallback.api.com/data'),
]);
Async Iteration
When dealing with paginated APIs or streams, for await...of keeps code clean:
async function* paginate(url) {
let nextUrl = url;
while (nextUrl) {
const res = await fetch(nextUrl);
const page = await res.json();
yield page.items;
nextUrl = page.nextPage ?? null;
}
}
for await (const items of paginate('/api/posts?limit=20')) {
console.log('got batch:', items.length);
}
Timeout Pattern
fetch doesn't time out by default. Wrap it with AbortController:
async function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
const res = await fetch(url, { signal: controller.signal });
return await res.json();
} finally {
clearTimeout(timer);
}
}
Retry with Exponential Backoff
async function withRetry(fn, { retries = 3, baseDelay = 300 } = {}) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === retries) throw err;
const delay = baseDelay * 2 ** attempt + Math.random() * 100;
await new Promise(r => setTimeout(r, delay));
}
}
}
// Usage
const data = await withRetry(() => fetchWithTimeout('/api/data'));
Common Pitfalls
Forgetting await in loops
// ❌ This fires all requests but doesn't wait for them
for (const id of ids) {
await processItem(id); // sequential by accident — use Promise.all instead
}
// ✅ Parallel
await Promise.all(ids.map(id => processItem(id)));
async in array callbacks
// ❌ .forEach doesn't await — the loop completes before promises resolve
items.forEach(async (item) => {
await save(item); // ignored!
});
// ✅ Use a for...of loop or Promise.all + .map
await Promise.all(items.map(item => save(item)));
These patterns cover the majority of real-world async code. The key is knowing which combinator fits the situation and never accidentally going sequential when parallel is possible.