Understanding the Event Loop First
JavaScript is single-threaded — it can only execute one thing at a time. Asynchronous operations (network requests, timers, file I/O) are handled by the browser's Web APIs or Node.js's libuv, which push callbacks onto the microtask queue when complete. Promises and async/await are different syntactic layers on top of this same event loop — they don't add concurrency, they add readable control flow for asynchronous operations.
Promises: The Foundation
A Promise represents a value that may be available now, in the future, or never. It has three states: pending, fulfilled, or rejected.
// Creating a Promise
const fetchUser = (id) => new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id, name: 'Alice' });
} else {
reject(new Error('Invalid user ID'));
}
}, 100);
});
// Consuming with .then()/.catch()
fetchUser(1)
.then(user => console.log(user.name))
.catch(err => console.error(err.message))
.finally(() => console.log('Done')); // Always runs
Promise Chaining
Each .then() returns a new Promise, enabling chains:
fetchUser(1)
.then(user => fetchUserPosts(user.id)) // Returns another Promise
.then(posts => renderPosts(posts))
.then(result => console.log('Rendered:', result))
.catch(err => {
// Catches errors from ANY step in the chain
console.error('Pipeline failed:', err);
});
Common mistake: forgetting to return the inner Promise. Without return, the chain doesn't wait for the inner async operation.
async/await: Syntactic Sugar
async/await makes asynchronous code look synchronous. An async function always returns a Promise. await pauses execution until the Promise settles:
async function loadUserDashboard(userId) {
const user = await fetchUser(userId);
const posts = await fetchUserPosts(user.id);
const comments = await fetchRecentComments(user.id);
return { user, posts, comments };
}
// Call it
loadUserDashboard(1)
.then(dashboard => render(dashboard))
.catch(err => showError(err));
Error Handling Comparison
Error handling differs significantly between the two approaches:
// Promises: .catch() handles all errors in chain
function fetchDataPromise(url) {
return fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.catch(err => {
console.error('Fetch failed:', err);
return null; // Return fallback
});
}
// async/await: try/catch looks like synchronous error handling
async function fetchDataAsync(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Fetch failed:', err);
return null;
}
}
The async/await version is more readable for complex multi-step operations with conditional error handling.
Concurrency with Promise.all
The biggest performance trap with async/await: using await sequentially when operations are independent. Use Promise.all for parallel execution:
// SLOW: Sequential — total time = t1 + t2 + t3
async function loadDataSequential(userId) {
const user = await fetchUser(userId); // 200ms
const posts = await fetchPosts(userId); // 150ms
const friends = await fetchFriends(userId); // 180ms
return { user, posts, friends }; // Total: ~530ms
}
// FAST: Parallel — total time = max(t1, t2, t3)
async function loadDataParallel(userId) {
const [user, posts, friends] = await Promise.all([
fetchUser(userId), // All three start simultaneously
fetchPosts(userId),
fetchFriends(userId),
]);
return { user, posts, friends }; // Total: ~200ms
}
Promise Combinators
// Promise.all — fails fast if any rejects
const results = await Promise.all([p1, p2, p3]);
// Promise.allSettled — waits for all, never rejects
const results = await Promise.allSettled([p1, p2, p3]);
results.forEach(result => {
if (result.status === 'fulfilled') console.log(result.value);
else console.error(result.reason);
});
// Promise.race — settles with the first to finish
const fastest = await Promise.race([fetchFromRegion1(), fetchFromRegion2()]);
// Promise.any — resolves with first fulfilled (ignores rejections)
const firstSuccess = await Promise.any([mirror1(), mirror2(), mirror3()]);
When to Use Each
| Scenario | Promise | async/await |
|---|---|---|
| Simple single async call | OK | Preferred |
| Multiple parallel requests | Promise.all | await Promise.all |
| Complex conditional flow | Messy | Preferred |
| Event handlers / streams | Preferred | Awkward |
Common Pitfalls
// Pitfall 1: Unhandled rejection
fetchUser(1) // No .catch() — silently fails in older Node.js
// Pitfall 2: await in forEach (doesn't work as expected)
[1, 2, 3].forEach(async (id) => {
const user = await fetchUser(id); // forEach doesn't await these
});
// Fix: use for...of or Promise.all
for (const id of [1, 2, 3]) {
const user = await fetchUser(id); // Sequential
}
await Promise.all([1, 2, 3].map(id => fetchUser(id))); // Parallel
// Pitfall 3: Returning a Promise inside async (double-wrapping)
async function getUser() {
return await fetchUser(1); // Redundant await, but not harmful
// return fetchUser(1); // This also works fine
}
Frequently Asked Questions
Is async/await faster than Promises?
No — async/await is syntactic sugar over Promises. There's no performance difference. In fact, the V8 engine optimizes async functions heavily, so async/await can be marginally faster in hot paths due to fewer intermediate Promise allocations.
Can I use await at the top level?
Yes — with ES2022 Top-Level Await, you can use await directly in ES module files (.mjs) without wrapping in an async function. Widely supported in modern Node.js and all modern browsers.
How do I cancel a Promise?
Promises themselves are not cancellable. Use AbortController with fetch, or implement your own cancellation token pattern. Many libraries (axios, SWR) provide built-in cancellation support.
Build and test JavaScript functions with DevKits JSON Formatter to inspect async API responses. Use the DevKits Regex Tester for parsing patterns in async data processing pipelines.