JavaScript is single-threaded, yet it handles thousands of concurrent operations — API calls, file reads, timers, user events. It does this through asynchronous programming. Understanding Promises and async/await is non-negotiable for any JavaScript developer.
The trick behind this is the event loop. When you start a slow operation — a network request, a timer, a disk read — JavaScript hands it off to the host environment and keeps executing other code. When the operation finishes, a function you supplied is queued to run with the result. Everything in this article — callbacks, Promises, async/await — is just increasingly ergonomic syntax for describing "run this code later, when that result arrives".
The Evolution: Callbacks → Promises → Async/Await
The Callback Era
The original pattern was to pass a function that gets invoked when the work completes. It works, but it scales terribly: each dependent step nests one level deeper, producing the rightward drift known as the "pyramid of doom". Worse, error handling must be repeated by hand at every level, and you hand control of your continuation to someone else's code — if a poorly written library calls your callback twice, or never, you have no defense.
// Nested callbacks become unreadable quickly
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetails(orders[0].id, (err, details) => {
if (err) return handleError(err);
console.log(details); // Finally!
});
});
});
Promises: A Better Way
A Promise is an object representing a value that will exist later. It is always in one of three states — pending, fulfilled, or rejected — and once settled, its state never changes again. The crucial design decision is that .then() returns a new promise, so steps chain flat instead of nesting, and a single .catch() at the end handles a failure from any step above it. .finally() runs regardless of outcome, which makes it the natural home for cleanup like hiding a spinner.
getUser(userId) .then(user => getOrders(user.id)) .then(orders => getOrderDetails(orders[0].id)) .then(details => console.log(details)) .catch(err => handleError(err)) .finally(() => hideSpinner());
Async/Await: Promises Made Readable
Async/await is syntax over promises, not a new mechanism. Marking a function async makes it always return a promise; await pauses that function — and only that function, never the thread — until the promise settles. The payoff is that asynchronous code regains the shape of synchronous code: normal try/catch for errors, normal loops and conditionals, values in plain variables. Anything you can do with .then() chains you can do with await, usually more readably.
async function getFullOrder(userId) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id);
const details = await getOrderDetails(orders[0].id);
return details;
} catch (err) {
handleError(err);
} finally {
hideSpinner();
}
}
Creating Promises
Most of the time you consume promises that APIs like fetch already return, but the new Promise() constructor is how you create one from scratch — chiefly to wrap older callback-based APIs. The executor function receives resolve and reject and runs immediately; whichever of the two you call first settles the promise, and any later calls are ignored. The delay helper below is the classic minimal example, turning setTimeout into something awaitable.
The timeout wrapper illustrates a subtlety worth knowing: rejecting the promise does not abort the underlying fetch, which keeps running in the background. To genuinely cancel the request, pass an AbortSignal — in modern runtimes, fetch(url, { signal: AbortSignal.timeout(ms) }) achieves the same goal with real cancellation.
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function fetchWithTimeout(url, ms) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Timeout")), ms);
fetch(url)
.then(response => {
clearTimeout(timer);
resolve(response);
})
.catch(err => {
clearTimeout(timer);
reject(err);
});
});
}
Concurrent Operations
The most common async/await performance mistake is awaiting independent operations one after another: three sequential 300 ms requests take 900 ms, when they could all be in flight simultaneously. Promise.all starts them together and resolves when every one succeeds — but it is fail-fast, rejecting the moment any input rejects, which is what you want when the results are only useful together.
Promise.allSettled is the tolerant sibling: it never rejects, instead reporting a status object per input. Reach for it when partial results are acceptable — a dashboard should still render four widgets when the fifth data source is down.
// All must succeed (fails fast on first rejection)
const [users, products, orders] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/products").then(r => r.json()),
fetch("/api/orders").then(r => r.json()),
]);
// allSettled — Never rejects, returns status for each
const results = await Promise.allSettled([
fetch("/api/users"),
fetch("/api/products"), // even if this fails...
fetch("/api/orders"), // ...we still get results for others
]);
results.forEach(result => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.log("Failed:", result.reason);
}
});
Promise.race settles as soon as the first input settles, win or lose. Its classic use is imposing a timeout on an operation that lacks one. Note the same caveat as before: the "losing" promise is not cancelled — the slow fetch keeps running even after the race has rejected.
// Useful for timeouts
const result = await Promise.race([
fetch("/api/data"),
delay(5000).then(() => { throw new Error("Timeout!"); })
]);
Error Handling Patterns
With async/await, a rejected promise becomes a thrown exception at the await site, so plain try/catch works. When many independent operations each need their own handling, though, try/catch blocks pile up. The Go-inspired wrapper below converts every promise into an [error, data] tuple, letting you handle failures with a simple if and keep the happy path unindented. It is a pattern, not a library — five lines you can paste into any project.
// Returns [error, data] tuple — no try/catch needed
async function to(promise) {
try {
const data = await promise;
return [null, data];
} catch (err) {
return [err, null];
}
}
// Usage
const [err, user] = await to(getUser(123));
if (err) {
console.error("Failed to get user:", err.message);
return;
}
console.log(user);
Common Mistakes
Almost every async bug in code review traces back to one of a handful of patterns. The most frequent by far is the missing await — the code compiles and runs, but hands you a pending Promise object where you expected data, often surfacing far from the actual mistake as "[object Promise]" in a template or undefined properties in a log line.
- Forgetting
await: Withoutawait, you get a Promise object, not its resolved value. - Sequential when you mean parallel:
await a(); await b();runs sequentially. UsePromise.all([a(), b()])for parallel. - Unhandled rejections: Always add
.catch()to promise chains or use try/catch with async/await. - Async in loops:
forEachdoesn't await. Usefor...oforPromise.all(arr.map(...)). - Creating unnecessary Promises: If a function already returns a Promise, don't wrap it in
new Promise().
Putting It into Practice
A workable mental model beats memorized syntax: every async function returns a promise, every await unwraps one, and errors travel down the chain until something catches them. When writing new code, default to async/await with try/catch, use Promise.all whenever your awaits do not depend on each other, and switch to allSettled where partial failure is tolerable.
To make it stick, refactor one real callback- or chain-heavy function in your own codebase to async/await, then open the browser DevTools and step through it — watching execution jump at each await teaches the model faster than any article can. From there, explore for await...of and async iterators for processing streams of data, the natural next chapter of asynchronous JavaScript.
Try Our Free JavaScript Tools
Minify and beautify your JavaScript code instantly.