Master asynchronous JavaScript — understand the event loop, Promises, async/await, error handling, parallel execution, and common pitfalls.
Asynchronous programming is JavaScript's superpower — and its biggest source of bugs. This guide takes you from callbacks through Promises to async/await, with the patterns that actually matter in production.
JavaScript is single-threaded. The event loop lets it handle async operations without blocking:
Call Stack Microtask Queue Task Queue (macrotask)
---------- --------------- ---------------------
main() Promise callbacks setTimeout callbacks
fetchData() .then() handlers setInterval callbacks
async/await I/O callbacks
Order: Call Stack → Microtasks (ALL of them) → One Task → Microtasks → ...
Promises and async/await run in the microtask queue — they have priority over setTimeout callbacks.
// Creating a Promise
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const fetchUser = (id) => new Promise((resolve, reject) => {
fetch(`/api/users/${id}`)
.then(r => r.ok ? resolve(r.json()) : reject(new Error('Not found')))
.catch(reject);
});
// Consuming a Promise
fetchUser(1)
.then(user => console.log(user))
.catch(err => console.error(err))
.finally(() => console.log('always runs'));
// Chaining
fetchUser(1)
.then(user => fetchOrders(user.id)) // returns another Promise
.then(orders => display(orders))
.catch(err => handleError(err)); // catches any error in the chain
Async/await is syntactic sugar over Promises — it makes async code look synchronous:
// Equivalent to the .then() chain above
async function loadUserOrders(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
display(orders);
} catch (err) {
handleError(err);
} finally {
setLoading(false);
}
}
// await can only be used inside async functions
// (or at the top level of ES modules)
// Arrow function syntax
const loadUser = async (id) => {
const user = await fetchUser(id);
return user;
};
The most common performance mistake: awaiting things sequentially that could run in parallel.
// ✗ Sequential — 2000ms total
const user = await fetchUser(id); // 1000ms
const posts = await fetchPosts(id); // 1000ms
// ✓ Parallel — 1000ms total
const [user, posts] = await Promise.all([
fetchUser(id),
fetchPosts(id),
]);
// Promise.allSettled — continues even if some fail
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
const successes = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
// Promise.race — first one wins
const result = await Promise.race([
fetch('/api/data'),
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000)),
]);
// Promise.any — first successful one wins (ES2021)
const fastest = await Promise.any([fetchFromCDN1(), fetchFromCDN2()]);
// Pattern 1: try/catch (best for complex flows)
async function getData() {
try {
const data = await riskyOperation();
return data;
} catch (err) {
if (err instanceof NetworkError) return null; // recoverable
throw err; // rethrow unrecoverable errors
}
}
// Pattern 2: Go-style [error, result] (avoids nested try/catch)
async function safeAwait(promise) {
try {
return [null, await promise];
} catch (err) {
return [err, null];
}
}
const [err, user] = await safeAwait(fetchUser(id));
if (err) return handleError(err);
// Pattern 3: .catch() on the promise (for optional errors)
const user = await fetchUser(id).catch(() => null); // null on error
if (!user) return notFound();
// ✗ Mistake 1: Forgetting await (promise goes unhandled)
async function bad() {
const data = fetchData(); // missing await — data is a Promise!
console.log(data.value); // undefined
}
// ✗ Mistake 2: await inside forEach (doesn't work!)
items.forEach(async item => {
await process(item); // forEach doesn't await these
});
// ✓ Use for...of for sequential async
for (const item of items) {
await process(item);
}
// ✓ Use Promise.all for parallel async
await Promise.all(items.map(item => process(item)));
// ✗ Mistake 3: Creating unnecessary Promise wrappers
async function bad() {
return new Promise(resolve => { // redundant!
resolve(fetch('/api'));
});
}
// ✓ Just return the promise directly
async function good() {
return fetch('/api');
}
// ✗ Mistake 4: Not catching unhandled rejections
// In Node.js, unhandled promise rejections crash the process (Node 15+)
// Always handle errors or attach .catch()
// Async generators — for streaming data
async function* streamData(url) {
const response = await fetch(url);
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield new TextDecoder().decode(value);
}
}
// Consume with for-await-of
for await (const chunk of streamData('/api/stream')) {
process.stdout.write(chunk);
}