Promises and async/await are the backbone of modern JavaScript. Once you understand how the event loop, Promise states, and await interact, async code becomes as readable as synchronous code. This guide covers everything from basics to production patterns.
Quick-reference table
| Syntax | Purpose | Returns |
|---|---|---|
new Promise((resolve, reject) => {}) |
Create a promise | Promise<T> |
promise.then(fn) |
Handle resolved value | Promise<T> |
promise.catch(fn) |
Handle rejection | Promise<T> |
promise.finally(fn) |
Run regardless of outcome | Promise<T> |
await promise |
Wait for settlement (async fn only) | T or throws |
Promise.resolve(value) |
Create pre-resolved promise | Promise<T> |
Promise.reject(reason) |
Create pre-rejected promise | Promise<never> |
Promise.all([...]) |
Wait for all; fail fast | Promise<T[]> |
Promise.allSettled([...]) |
Wait for all; never rejects | Promise<Result[]> |
Promise.race([...]) |
First to settle wins | Promise<T> |
Promise.any([...]) |
First to resolve wins | Promise<T> |
Promise states
A Promise is always in one of three states:
Pending → Fulfilled (resolved with a value)
Pending → Rejected (rejected with a reason)
Once settled (fulfilled or rejected), a Promise never changes state. This is the key invariant.
const p = new Promise((resolve, reject) => {
// Async work here
setTimeout(() => resolve("done"), 1000);
});
console.log(p); // Promise { <pending> }
p.then((value) => console.log(value)); // "done" (after 1s)
Creating promises
// Wrapping a callback-based API
function readFile(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, "utf8", (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
// Already-settled promises (useful in tests)
Promise.resolve(42).then(console.log); // 42
Promise.reject(new Error("oops")).catch(console.error); // Error: oops
.then() chaining
Each .then() returns a new Promise with the return value of the callback:
fetch("https://api.example.com/user/1")
.then((res) => res.json()) // returns Promise<data>
.then((data) => data.name) // returns Promise<string>
.then((name) => console.log(name)) // "Alice"
.catch((err) => console.error(err));
Rule: If you return a value from .then(), the next .then() gets that value. If you return a Promise, the chain waits for it.
// Returning a Promise flattens the chain (no nested Promise<Promise<T>>)
Promise.resolve(1)
.then((n) => Promise.resolve(n + 1)) // returns Promise<2>, not Promise<Promise<2>>
.then((n) => console.log(n)); // 2
async / await
async functions always return a Promise. await pauses execution inside the async function until the Promise settles.
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data;
}
// Calling an async function
fetchUser(1).then(console.log).catch(console.error);
// Or inside another async function
async function main() {
const user = await fetchUser(1);
console.log(user.name);
}
Error handling with try/catch
async function loadData() {
try {
const res = await fetch("/api/data");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error("Failed:", err.message);
return null; // graceful fallback
}
}
Sequential vs parallel execution
Sequential (one after another)
async function sequential() {
const a = await fetchA(); // waits for A
const b = await fetchB(); // then waits for B
// Total time: time(A) + time(B)
return [a, b];
}
Parallel (both at once)
async function parallel() {
const [a, b] = await Promise.all([fetchA(), fetchB()]);
// Total time: max(time(A), time(B))
return [a, b];
}
Start both, then await both — don't await inside the array literal:
// ✅ Correct: both start immediately
const pA = fetchA();
const pB = fetchB();
const [a, b] = await Promise.all([pA, pB]);
// ❌ Wrong: sequential despite Promise.all
const [a, b] = await Promise.all([await fetchA(), await fetchB()]);
Promise combinators
Promise.all — all or nothing
Resolves with all values. Rejects immediately if any rejects.
const results = await Promise.all([
fetch("/api/users"),
fetch("/api/posts"),
fetch("/api/comments"),
]);
// If any fetch fails, the whole thing rejects
Promise.allSettled — wait for all, never rejects
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(2),
fetchUser(999), // might fail
]);
for (const result of results) {
if (result.status === "fulfilled") {
console.log("OK:", result.value);
} else {
console.error("Failed:", result.reason);
}
}
Use allSettled when you want partial results even if some fail.
Promise.race — first to settle wins
// Timeout pattern
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), ms)
);
return Promise.race([promise, timeout]);
}
const data = await withTimeout(fetchData(), 5000);
Promise.any — first to resolve wins
Rejects only if all promises reject (AggregateError).
// Try multiple CDNs, use whichever responds first
const asset = await Promise.any([
fetch("https://cdn1.example.com/lib.js"),
fetch("https://cdn2.example.com/lib.js"),
fetch("https://cdn3.example.com/lib.js"),
]);
Practical patterns
Retry with backoff
async function retry(fn, retries = 3, delay = 500) {
try {
return await fn();
} catch (err) {
if (retries === 0) throw err;
await new Promise((r) => setTimeout(r, delay));
return retry(fn, retries - 1, delay * 2);
}
}
const data = await retry(() => fetch("/api/flaky").then((r) => r.json()));
Concurrent with limit
async function batchProcess(items, fn, concurrency = 3) {
const results = [];
for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency);
const batchResults = await Promise.all(batch.map(fn));
results.push(...batchResults);
}
return results;
}
const processed = await batchProcess(urls, fetchAndProcess);
Async initialization (singleton)
class DbClient {
static #instance = null;
static #initPromise = null;
static async getInstance() {
if (!DbClient.#instance) {
if (!DbClient.#initPromise) {
DbClient.#initPromise = DbClient.#init();
}
DbClient.#instance = await DbClient.#initPromise;
}
return DbClient.#instance;
}
static async #init() {
// expensive setup
return new DbClient();
}
}
async in forEach — it doesn't work as expected
// ❌ forEach doesn't await — all fire simultaneously, forEach returns before they finish
items.forEach(async (item) => {
await processItem(item);
});
// ✅ Use for...of for sequential
for (const item of items) {
await processItem(item);
}
// ✅ Use Promise.all for parallel
await Promise.all(items.map((item) => processItem(item)));
6 common mistakes
1. Forgetting to await
// ❌ data is a Promise, not the value
const data = fetchData();
console.log(data.name); // undefined
// ✅
const data = await fetchData();
console.log(data.name);
2. Unhandled rejections
// ❌ If fetchData() rejects, you get UnhandledPromiseRejection
fetchData().then(process);
// ✅ Always attach .catch() or use try/catch with await
fetchData().then(process).catch(handleError);
3. Promise constructor anti-pattern
// ❌ Wrapping an already-async function in new Promise
async function bad() {
return new Promise(async (resolve, reject) => {
try {
const data = await fetch("/api");
resolve(data);
} catch (e) {
reject(e);
}
});
}
// ✅ Just use async/await directly
async function good() {
return fetch("/api"); // async functions auto-wrap in Promise
}
4. Missing await inside try/catch
// ❌ catch doesn't catch the rejection (promise created outside try)
async function bad() {
try {
const p = fetchData(); // starts the promise
doSomethingElse();
return await p; // if this throws, DOES get caught
} catch (e) { /* fine */ }
}
// Actually the real trap:
async function trap() {
try {
return fetchData(); // ❌ no await — rejection escapes the try/catch
} catch (e) { /* never runs */ }
}
5. Sequential instead of parallel
// ❌ 3 seconds total (1s + 1s + 1s)
const a = await delay(1000);
const b = await delay(1000);
const c = await delay(1000);
// ✅ 1 second total
const [a, b, c] = await Promise.all([delay(1000), delay(1000), delay(1000)]);
6. Promise.all fail-fast in production
// ❌ One failure kills everything
const results = await Promise.all([criticalOp(), optionalOp()]);
// ✅ Use allSettled if some operations are optional
const [critical, optional] = await Promise.allSettled([criticalOp(), optionalOp()]);
if (critical.status === "rejected") throw critical.reason;
const optionalData = optional.status === "fulfilled" ? optional.value : null;
6 FAQ
Q: When should I use .then() vs async/await?
Both are equivalent. Prefer async/await for readability — it reads like synchronous code and makes error handling with try/catch straightforward. Use .then() when chaining short transformations or in non-async contexts.
Q: Is await blocking?
No. await pauses only the current async function — it yields control back to the event loop. Other code (other promises, event handlers, timers) can run while your function is "waiting."
Q: Can I use await at the top level?
Yes — in ES modules (.mjs files or "type": "module" in package.json). In CommonJS (require), you must wrap in an async function.
Q: What's the difference between Promise.all and Promise.allSettled?Promise.all rejects immediately if any promise rejects (fail-fast). Promise.allSettled always resolves with an array of {status, value | reason} objects — useful when you want partial results.
Q: How do I convert a callback-based API to a Promise?
Wrap it in new Promise((resolve, reject) => { ... }). Node.js also provides util.promisify() for standard (err, result) callbacks:
const { promisify } = require("util");
const readFile = promisify(fs.readFile);
const content = await readFile("file.txt", "utf8");
Q: Why does async in map/forEach not work as expected?Array.map() returns an array of Promises, not resolved values. Use Promise.all(arr.map(async fn)) to wait for all. forEach ignores the returned Promises entirely — use for...of for sequential async iteration.
Understanding Promises and async/await unlocks all modern JavaScript APIs: fetch, Web Crypto, IndexedDB, Service Workers, and virtually every Node.js library. Start with the quick-reference table above, then practice the parallel vs sequential pattern — that distinction alone eliminates most async bugs.