Async/Await in JavaScript: From Callbacks to Clean Code (2026)
Async JavaScript has come a long way. Here's the complete picture — from callbacks to async/await and everything in between.
The Evolution
// ERA 1: Callbacks (2010-ish) — The "Callback Hell"
getData(function(a) {
getMoreData(a, function(b) {
getEvenMoreData(b, function(c) {
getFinalData(c, function(finalData) {
console.log(finalData);
});
});
});
});
// → Pyramid of doom → Hard to read → Error handling is a nightmare
// ERA 2: Promises (2015) — Chainable, but still verbose
getData()
.then(a => getMoreData(a))
.then(b => getEvenMoreData(b))
.then(c => getFinalData(c))
.then(finalData => console.log(finalData))
.catch(err => console.error(err));
// → Better! Linear flow. But still lots of .then() chains.
// ERA 3: Async/Await (2017+) — Looks like synchronous code!
async function processData() {
const a = await getData();
const b = await getMoreData(a);
const c = await getEvenMoreData(b);
const finalData = await getFinalData(c);
console.log(finalData); // So clean!
}
// → Read top to bottom. Error handling with try/catch. Winner.
Promises Deep Dive (You Need This Foundation)
Creating Promises
// Basic promise constructor
function fetchData(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = () => {
if (xhr.status === 200) resolve(xhr.response);
else reject(new Error(`HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send();
});
}
// Most APIs already return promises:
fetch(url).then(res => res.json());
fs.promises.readFile(path, 'utf8');
Promise Methods You Should Know
// Promise.all — Run in parallel, wait for ALL
const [users, posts, comments] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json()),
]);
// ALL must succeed. One fails = all fail.
// Promise.allSettled — Run all, get results regardless of success/failure
const results = await Promise.allSettled([
fetch('/api/users'),
fetch('/api/posts'), // Maybe this one fails
fetch('/api/comments'),
]);
results.forEach(r => {
if (r.status === 'fulfilled') console.log(r.value);
else console.error('Failed:', r.reason);
});
// Promise.race — First to finish wins (success or failure)
const result = await Promise.race([
fetch('/api/fast-server'),
fetchWithTimeout('/api/slow-server', 3000), // Custom timeout wrapper
]);
// Promise.any — First SUCCESSFUL result wins (ignores failures until all fail)
const data = await Promise.any([
fetchFromPrimary(url),
fetchFromBackup(url),
fetchFromCache(url), // At least one needs to succeed
});
// Common pattern: Timeout wrapper
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
),
]);
}
await withTimeout(fetch('/api/heavy'), 5000); // Throws after 5s
Async/Await Complete Guide
Syntax Basics
// async ALWAYS returns a Promise
async function hello() {
return 'Hello!'; // Implicitly wrapped in Promise.resolve()
}
hello().then(console.log); // "Hello!"
// await pauses execution INSIDE async functions
async function getUser(id) {
const response = await fetch(`/api/users/${id}`); // Pauses here
const user = await response.json(); // Then resumes
return user;
}
// Can use in arrow functions too
const getUser = async (id) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
};
Parallel vs Sequential (Performance!)
// ❌ SLOW: Sequential awaits (each waits for previous)
async function getAllDataSlow() {
const users = await fetch('/api/users'); // Wait 100ms
const posts = await fetch('/api/posts'); // Wait another 150ms
const comments = await fetch('/api/comments');// Wait another 80ms
// Total: ~330ms
}
// ✅ FAST: Independent calls run in parallel
async function getAllDataFast() {
const [usersRes, postsRes, commentsRes] = await Promise.all([
fetch('/api/users'), // All start simultaneously
fetch('/api/posts'),
fetch('/api/comments'),
]); // Total: ~150ms (slowest one)
const [users, posts, comments] = await Promise.all([
usersRes.json(),
postsRes.json(),
commentsRes.json(),
]);
return { users, posts, comments };
}
Error Handling
// Method 1: try/catch (most common)
async function safeFetch(url) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (err) {
console.error(`Failed to fetch ${url}:`, err.message);
return null; // Or rethrow, or return fallback value
}
}
// Method 2: Higher-order error handler
function withErrorHandler(fn) {
return (...args) => fn(...args).catch(err => {
console.error(`[Error in ${fn.name}]:`, err);
return null; // Prevent unhandled rejection
});
}
const safeGetUser = withErrorHandler(getUser);
// Method 3: Go pattern (explicit error handling)
async function loadData() {
let [err, data] = await to(promiseHere());
if (err) { /* handle */ }
// use data
}
// Helper for Go-style error handling
function to(promise) {
return promise.then(data => [null, data], err => [err, null]);
}
Looping with Async/Await
// ❌ WRONG: forEach ignores async/await
urls.forEach(async url => {
const data = await fetch(url); // Fires but doesn't await!
}); // Moves on immediately, doesn't wait for fetches
// ✅ for...of loop (sequential, each awaits)
for (const url of urls) {
const data = await fetch(url); // Waits for each one
process(data);
}
// ✅ for...of + Promise.all inside (parallel batches)
const batchSize = 5;
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
const results = await Promise.all(batch.map(url => fetch(url)));
processBatch(results);
}
// ✅ map + Promise.all (fully parallel)
const results = await Promise.all(urls.map(async url => {
const res = await fetch(url);
return res.json();
}));
Async Iterators & Generators
// Generator that yields items asynchronously
async function* paginate(endpoint, pageSize = 50) {
let page = 1;
while (true) {
const response = await fetch(`${endpoint}?page=${page}&size=${pageSize}`);
const data = await response.json();
if (data.items.length === 0) break; // No more pages
yield* data.items; // Yield each item
page++;
}
}
// Usage: consume with for-await-of
for await (const item of paginate('/api/users')) {
console.log(item.name);
// Processes one at a time, memory-efficient for huge datasets
}
// Or collect into array
const allUsers = [];
for await (const user of paginate('/api/users')) {
allUsers.push(user);
}
Real-World Patterns
Retry Logic
async function retry(fn, options = {}) {
const {
maxAttempts = 3,
delayMs = 1000,
backoff = 2, // Exponential backoff multiplier
shouldRetry = (e) => true, // Conditionally retry based on error
} = options;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === maxAttempts || !shouldRetry(err)) {
throw err; // Final attempt or non-retryable error
}
const waitTime = delayMs * Math.pow(backoff, attempt - 1);
console.warn(`Attempt ${attempt}/${maxAttempts} failed. Retrying in ${waitTime}ms...`);
await sleep(waitTime);
}
}
}
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
// Usage
const data = await retry(() => fetch('https://flaky-api.com/data').then(r => r.json()), {
maxAttempts: 5,
delayMs: 1000,
shouldRetry: (err) => err.message.includes('529') || err.message.includes('timeout'),
});
Request Caching / Deduplication
class RequestCache {
#cache = new Map();
async get(key, fetcher, ttlMs = 60_000) {
const cached = this.#cache.get(key);
if (cached && Date.now() - cached.timestamp < ttlMs) {
return cached.data; // Cache hit!
}
// In-flight deduplication: if request is already running, reuse it
if (cached?.inflight) {
return cached.inflight; // Return same promise
}
const inflight = fetcher().then(data => {
this.#cache.set(key, { data, timestamp: Date.now() });
return data;
}).finally(() => {
// Clear inflight flag when done
const entry = this.#cache.get(key);
if (entry?.inflight === inflight) entry.inflight = null;
});
this.#cache.set(key, { inflight, timestamp: Date.now() });
return inflight;
}
clear(key) { this.#cache.delete(key); }
clearAll() { this.#cache.clear(); }
}
const apiCache = new RequestCache();
// Multiple components calling same endpoint simultaneously
// → Only ONE actual HTTP request, others share the promise
const user1 = await apiCache.get('user:123', () => fetchUser(123));
const user2 = await apiCache.get('user:123', () => fetchUser(123)); // Cached!
Race Conditions & Mutex
// Problem: Double-click triggers two writes
async function updateProfile(data) {
await db.update(user.id, data); // Click once → OK
// Click twice → Two updates, potential conflict!
}
// Solution: Mutex (mutual exclusion)
class Mutex {
#locked = false;
#queue = [];
async acquire() {
while (this.#locked) {
await new Promise(resolve => this.#queue.push(resolve));
}
this.#locked = true;
}
release() {
this.#locked = false;
if (this.#queue.length > 0) {
this.#queue.shift()(); // Wake up next waiter
}
}
}
const profileMutex = new Mutex();
async function updateProfileSafe(data) {
await profileMutex.acquire();
try {
await db.update(user.id, data);
} finally {
profileMutex.release();
}
}
// Now rapid clicks queue up instead of racing.
Top Mistakes to Avoid
// ❌ Forgetting await in loops
async function processItems(items) {
items.forEach(async item => {
await process(item); // ❌ forEach doesn't await!
}); // Returns immediately, doesn't wait
}
// ✅ Use for...of
for (const item of items) {
await process(item); // ✅ Each one completes before next
}
// ❌ Unhandled promise rejection
async function risky() {
throw new Error('Oops');
}
risky(); // No .catch(), no await → UnhandledRejection!
// ✅ Always handle or delegate
risky().catch(err => console.error(err)); // Handled
// OR ensure caller awaits it
// ❌ Using await inside non-async callback
['a', 'b', 'c'].forEach(item => {
await process(item); // SyntaxError! Not in async function
});
// ✅ Wrap in async IIFE or use for...of
for (const item of ['a', 'b', 'c']) {
await process(item); // Works fine
}
// ❌ Mixing callbacks and promises incorrectly
fs.readFile(file, (err, data) => {
const result = await parse(data); // SyntaxError! Not async
});
// ✅ Use fs.promises (Promise-based API)
const data = await fs.promises.readFile(file, 'utf8');
const result = await parse(data);
// ❌ Blocking event loop with CPU-heavy work
async function heavyComputation(data) {
return processHugeArray(data); // Blocks entire Node.js process!
}
// ✅ Offload to worker thread or yield periodically
async function heavyComputationNonBlocking(data) {
const chunkSize = 10000;
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
processChunk(chunk);
await new Promise(r => setTimeout(r, 0)); // Yield to event loop
}
}
Quick Reference
| Pattern | Code |
|---|---|
| Create Promise | new Promise((resolve, reject) => {...}) |
| Wait for all | await Promise.all([p1, p2]) |
| All settled | await Promise.allSettled([p1, p2]) |
| First to finish | await Promise.race([p1, p2]) |
| First success | await Promise.any([p1, p2]) |
| Sequential loop | for (const x of xs) { await f(x); } |
| Parallel loop | await Promise.all(xs.map(x => f(x))) |
| Error handling | try { ... } catch (e) { ... } |
| Timeout | Promise.race([fetch(), timeout(ms)]) |
| Retry | Loop with exponential backoff |
| Async generator | async function* gen() { yield await ... } |
| Consume generator | for await (const x of gen()) { ... } |
What's your favorite async pattern? Still using Promises or fully team async/await?
Follow @armorbreak for more practical JS guides.
Top comments (0)