Articles › JavaScript
JavaScript Promises and Async/Await: A Complete Guide
Learn asynchronous JavaScript properly: callbacks, Promises, chaining, error handling, async/await, Promise.all vs allSettled vs race, cancellation with AbortController and common mistakes.
JavaScript runs on a single thread, yet it handles network requests, timers and user input without freezing. The secret is asynchronous programming, and the modern tools for it are Promises and async/await. This guide builds them up from first principles and ends with the patterns you'll use in real applications.
From callbacks to Promises
Originally, asynchronous APIs took callbacks:
getUser(1, (err, user) => {
if (err) return handle(err);
getOrders(user.id, (err, orders) => {
if (err) return handle(err);
getInvoice(orders[0].id, (err, invoice) => {
// … the "pyramid of doom"
});
});
});
Nested callbacks are hard to read, error handling is repeated everywhere, and it's easy to call a callback twice. Promises fix all of this.
What is a Promise?
A Promise is an object representing a value that will be available later. It's always in one of three states:
- pending — the operation hasn't finished yet,
- fulfilled — it completed with a value,
- rejected — it failed with a reason (usually an
Error).
Once settled (fulfilled or rejected), a Promise never changes again.
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const loadConfig = () =>
new Promise((resolve, reject) => {
const raw = localStorage.getItem("config");
raw ? resolve(JSON.parse(raw)) : reject(new Error("No config found"));
});
You'll rarely write new Promise yourself — most APIs (fetch, database clients, file APIs) already return Promises.
Consuming Promises: then, catch, finally
fetch("/api/users/1")
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json(); // returning a Promise chains it
})
.then((user) => console.log(user.name))
.catch((error) => console.error("Failed:", error.message))
.finally(() => hideSpinner());
Key rules:
- Whatever you return from
thenbecomes the value for the nextthen. Returning a Promise waits for it. - A thrown error or a rejected Promise skips to the nearest
catch. finallyruns either way — perfect for cleanup.
Warning:
fetchdoes not reject on HTTP errors like 404 or 500. It only rejects on network failure. Always checkresponse.ok.
async/await: Promises that read like synchronous code
async/await is syntax built on top of Promises:
async function loadUserWithOrders(id) {
const userResponse = await fetch(`/api/users/${id}`);
if (!userResponse.ok) throw new Error("User not found");
const user = await userResponse.json();
const ordersResponse = await fetch(`/api/users/${id}/orders`);
const orders = await ordersResponse.json();
return { ...user, orders };
}
- An
asyncfunction always returns a Promise. awaitpauses the function (not the whole program!) until the Promise settles.- Errors are handled with ordinary
try/catch:
try {
const data = await loadUserWithOrders(1);
render(data);
} catch (error) {
showToast(error.message);
} finally {
setLoading(false);
}
Sequential vs parallel
Awaiting independent operations one by one wastes time:
// ❌ ~3 seconds if each takes 1 second
const user = await getUser(id);
const posts = await getPosts(id);
const followers = await getFollowers(id);
Start them together and await them as a group:
// ✅ ~1 second
const [user, posts, followers] = await Promise.all([
getUser(id),
getPosts(id),
getFollowers(id),
]);
The Promise combinators
| Method | Resolves when | Rejects when | Use for |
|---|---|---|---|
Promise.all |
all fulfill (array of values) | any rejects (fail fast) | requests that all must succeed |
Promise.allSettled |
all settle (array of {status, value/reason}) |
never | independent tasks where partial success is fine |
Promise.race |
the first settles | the first rejects | timeouts |
Promise.any |
the first fulfills | all reject (AggregateError) |
fastest of several mirrors |
const results = await Promise.allSettled(urls.map((u) => fetch(u)));
const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
const failed = results.filter((r) => r.status === "rejected");
A timeout helper
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms} ms`)), ms)
);
return Promise.race([promise, timeout]);
}
Cancellation with AbortController
Promises can't be cancelled by themselves, but many APIs accept an AbortSignal:
const controller = new AbortController();
fetch("/api/search?q=react", { signal: controller.signal })
.then((r) => r.json())
.then(showResults)
.catch((err) => {
if (err.name === "AbortError") return; // expected
console.error(err);
});
controller.abort(); // e.g. the user typed a new query
Modern browsers also provide AbortSignal.timeout(5000) for built-in timeouts.
Processing items with limited concurrency
Promise.all on 1,000 URLs fires 1,000 requests at once. Process in batches instead:
async function mapInBatches(items, size, fn) {
const results = [];
for (let i = 0; i < items.length; i += size) {
const batch = items.slice(i, i + size);
results.push(...(await Promise.all(batch.map(fn))));
}
return results;
}
await mapInBatches(imageUrls, 5, uploadImage);
Common mistakes
- Forgetting
await— you get a Promise instead of the value, and errors become unhandled rejections. awaitinsideforEach—forEachignores returned Promises. Usefor...offor sequential work orPromise.all(items.map(...))for parallel work.
// ❌ doesn't wait
items.forEach(async (item) => await save(item));
// ✅ sequential
for (const item of items) await save(item);
// ✅ parallel
await Promise.all(items.map(save));
- Mixing
thenandawaitrandomly — pick one style per function. - Swallowing errors — an empty
catch {}hides real problems. Log or rethrow. - Unhandled rejections — every Promise chain needs a
catchsomewhere; in Node.js an unhandled rejection can crash the process.
How it works under the hood
Promise callbacks run as microtasks. After the current synchronous code finishes, the event loop drains the microtask queue before handling the next timer or event:
console.log("1");
setTimeout(() => console.log("4"), 0); // macrotask
Promise.resolve().then(() => console.log("3")); // microtask
console.log("2");
// 1, 2, 3, 4
Understanding this ordering explains many "why did this run first?" puzzles.
Summary
Promises represent future values and let you chain operations with clean error handling. async/await makes that code read like ordinary sequential logic. Run independent work in parallel with Promise.all, choose allSettled, race or any for other scenarios, cancel with AbortController, and avoid await inside forEach. With these patterns, asynchronous JavaScript becomes predictable and easy to maintain.
FAQ
Is async/await faster than then()?
No — it's the same Promise machinery. The benefit is readability and simpler error handling.
Can I use await at the top level?
Yes, in ES modules (type="module" in browsers, .mjs or "type": "module" in Node.js).
Should I return await inside try/catch?
Inside a try block, return await promise ensures rejections are caught by that catch. Outside of try, return promise is enough.