cheat sheet
Promises
JavaScript Promises represent the eventual completion or failure of an async operation. Covers states, chaining, combinators, callback conversion, AbortController, and common anti-patterns.
Promises
What it is
A Promise is an object representing the eventual completion or failure of an asynchronous operation. It is the foundation of JavaScript async programming — every async/await expression compiles down to Promise chains, and the Fetch API, timers wrapped via promisify, and most browser/Node APIs return Promises.
Promise states
A Promise is always in exactly one of three states:
| State | Description | Transitions to |
|---|---|---|
| pending | Initial state; neither fulfilled nor rejected | fulfilled or rejected |
| fulfilled | Operation completed successfully; has a result value | (terminal) |
| rejected | Operation failed; has a rejection reason (error) | (terminal) |
Once a Promise settles (fulfilled or rejected) it never changes state.
Creating a Promise
The Promise constructor takes an executor function that receives two callbacks: call resolve(value) to fulfill the promise and reject(error) to reject it. The executor runs synchronously; the resolution is asynchronous. Only wrap APIs that aren't already Promise-based — wrapping an existing Promise is an anti-pattern.
const p = new Promise((resolve, reject) => {
// Perform async work here
setTimeout(() => {
const success = true;
if (success) {
resolve("done"); // fulfills the promise with "done"
} else {
reject(new Error("something went wrong")); // rejects with an Error
}
}, 1000);
});
Always pass an
Errorobject (not a plain string) toreject(). It preserves the stack trace and works correctly with.catch().
.then(), .catch(), .finally()
fetch("/api/users")
.then((response) => response.json()) // runs on fulfillment
.then((data) => console.log(data)) // chained; receives previous return value
.catch((err) => console.error(err)) // runs on any rejection in the chain
.finally(() => console.log("done")); // always runs; receives no argument
.then() accepts two arguments: onFulfilled and onRejected. Using .catch(fn) is shorthand for .then(undefined, fn).
Each .then() returns a new Promise, enabling chaining. The value returned inside a .then() callback becomes the resolved value of that new Promise.
Promise.resolve(1)
.then((v) => v + 1) // 2
.then((v) => v * 3) // 6
.then(console.log); // 6
Output:
6
Promise.resolve() and Promise.reject()
Promise.resolve(value) wraps a synchronous value in an already-fulfilled Promise, which is useful for normalising APIs that sometimes return a value and sometimes a Promise. Promise.reject(reason) creates an immediately-rejected Promise — always pass an Error instance to preserve the stack trace.
// Wrap an already-known value in a resolved Promise
const p1 = Promise.resolve(42);
p1.then(console.log); // 42
// Wrap a known error in a rejected Promise
const p2 = Promise.reject(new Error("bad"));
p2.catch(console.error); // Error: bad
If you pass another Promise to Promise.resolve(), it returns that same Promise (no double-wrapping).
Promise combinators
Promise.all() — all must resolve
Fulfills when every input Promise fulfills; rejects immediately on the first rejection.
const [user, posts] = await Promise.all([
fetch("/api/user").then((r) => r.json()),
fetch("/api/posts").then((r) => r.json()),
]);
console.log(user, posts);
Output:
{ id: 1, name: 'Jay' } [ { id: 1, title: 'Hello' } ]
If any Promise rejects, the whole Promise.all() rejects and you get nothing from the others.
Promise.allSettled() — waits for all
Always fulfills (never rejects) when all input Promises have settled. Returns an array of status descriptor objects.
const results = await Promise.allSettled([
Promise.resolve("ok"),
Promise.reject(new Error("fail")),
Promise.resolve("also ok"),
]);
for (const result of results) {
if (result.status === "fulfilled") {
console.log("value:", result.value);
} else {
console.log("reason:", result.reason.message);
}
}
Output:
value: ok
reason: fail
value: also ok
Promise.race() — first to settle wins
Fulfills or rejects with the outcome of whichever Promise settles first.
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("timeout")), 3000)
);
const result = await Promise.race([fetch("/api/data"), timeout]);
Promise.any() — first to fulfill wins
Fulfills with the first fulfilled Promise; rejects with an AggregateError only if all reject.
const fastest = await Promise.any([
fetch("https://cdn1.example.com/data"),
fetch("https://cdn2.example.com/data"),
fetch("https://cdn3.example.com/data"),
]);
const data = await fastest.json();
// All-reject case
try {
await Promise.any([
Promise.reject(new Error("a")),
Promise.reject(new Error("b")),
]);
} catch (err) {
console.log(err instanceof AggregateError); // true
console.log(err.errors.map((e) => e.message)); // ['a', 'b']
}
Output:
true
[ 'a', 'b' ]
Combinator comparison
| Method | Resolves when | Rejects when | Useful for |
|---|---|---|---|
Promise.all() | All fulfill | First rejection | Parallel requests where you need all results |
Promise.allSettled() | All settle (any outcome) | Never | Parallel requests where partial success is acceptable |
Promise.race() | First to settle | First to reject | Timeout races |
Promise.any() | First to fulfill | All reject | Fastest CDN / redundant endpoints |
Converting callbacks to Promises
Node.js util.promisify
import { promisify } from "node:util";
import { readFile } from "node:fs";
const readFileAsync = promisify(readFile);
const content = await readFileAsync("./data.txt", "utf8");
console.log(content);
Manual wrapping
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
await delay(500);
console.log("500 ms later");
// Wrapping a legacy callback API: cb(err, result)
function readJsonFile(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, "utf8", (err, data) => {
if (err) return reject(err);
try {
resolve(JSON.parse(data));
} catch (parseErr) {
reject(parseErr);
}
});
});
}
AbortController + Promise.race()
Combine AbortController with Promise.race to cancel a fetch if it takes too long: the timeout rejects the race and the AbortController signal tells the network layer to drop the in-flight request. Node 17.3+ / modern browsers also provide AbortSignal.timeout(ms) as a one-liner.
function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return fetch(url, { signal: controller.signal }).finally(() =>
clearTimeout(timer)
);
}
// Node 17.3+ shorthand
const response = await fetch("/api/data", {
signal: AbortSignal.timeout(5000),
});
.then() chain vs async/await
| Aspect | .then() chain | async/await |
|---|---|---|
| Readability | Gets noisy with error handling | Reads like synchronous code |
| Error handling | .catch() at end of chain | try/catch blocks |
| Debugging | Stack traces can be sparse | Stack traces are richer |
| Parallel execution | Promise.all([...]) | Promise.all([...]) (same) |
| Conditional branching | Nested .then() calls | Plain if/else inside async fn |
| Sequential loops | .reduce() trick | for...of + await |
// Sequential loop with async/await
async function processAll(items) {
const results = [];
for (const item of items) {
results.push(await processItem(item)); // one at a time
}
return results;
}
// Parallel with async/await
async function processAllParallel(items) {
return Promise.all(items.map((item) => processItem(item)));
}
Common anti-patterns
Promise constructor anti-pattern
// BAD: wrapping a function that already returns a Promise
function getUserBad(id) {
return new Promise((resolve, reject) => {
fetch(`/api/users/${id}`)
.then((r) => r.json())
.then(resolve)
.catch(reject);
});
}
// GOOD: just return the chain directly
function getUserGood(id) {
return fetch(`/api/users/${id}`).then((r) => r.json());
}
Forgotten .catch()
// BAD: unhandled rejection; Node.js will warn/crash
doSomethingAsync().then((result) => use(result));
// GOOD: always handle rejections
doSomethingAsync()
.then((result) => use(result))
.catch((err) => console.error("Failed:", err));
Broken chain (not returning the inner Promise)
// BAD: the inner fetch is not chained; p resolves to undefined
const p = Promise.resolve().then(() => {
fetch("/api/data").then((r) => r.json()); // missing return
});
// GOOD
const p = Promise.resolve().then(() => {
return fetch("/api/data").then((r) => r.json());
});
Sequential work written as parallel by mistake
// BAD: if step2 depends on step1's result, this is wrong
const [a, b] = await Promise.all([step1(), step2()]);
// GOOD: sequential when there is a dependency
const a = await step1();
const b = await step2(a);
The microtask queue and execution order
Every settled Promise's .then/.catch/.finally callback is scheduled as a microtask. Microtasks run on the host's microtask queue, which is drained completely between every macrotask (timer, I/O event, message). This means:
- Promise callbacks always run after the currently-executing synchronous code finishes.
- Promise callbacks run before the next
setTimeout(_, 0)orsetImmediate(Node) callback. - Nested microtasks added during the drain are processed in the same drain — the queue runs to empty before yielding.
console.log("1: sync start");
setTimeout(() => console.log("4: timer (macrotask)"), 0);
Promise.resolve().then(() => console.log("3: microtask"));
queueMicrotask(() => console.log("3b: microtask (direct)"));
console.log("2: sync end");
Output:
1: sync start
2: sync end
3: microtask
3b: microtask (direct)
4: timer (macrotask)
A long microtask chain can starve I/O — a .then() that itself returns a Promise re-schedules a microtask each link, and the event loop cannot service pending macrotasks until the queue empties. Don't put an unbounded recursive Promise chain on the microtask queue.
// Starves the event loop — never yields to timers / I/O until done
function loop(i) {
if (i === 0) return;
return Promise.resolve().then(() => loop(i - 1));
}
loop(1_000_000);
For genuinely long async work, yield to macrotasks periodically:
async function yieldToEventLoop() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
Promise.all — preserving partial results on rejection
Promise.all rejects on the first rejection and surfaces only the rejection reason — the other in-flight promises continue executing in the background, but their results are lost. If you need to know what completed before the failure, two patterns are common.
Pattern 1 — track results manually
async function allWithProgress(promises) {
const results = new Array(promises.length);
let firstRejection;
await Promise.all(
promises.map((p, i) =>
p.then(
(v) => (results[i] = { ok: true, value: v }),
(e) => {
if (!firstRejection) firstRejection = e;
results[i] = { ok: false, reason: e };
}
)
)
);
if (firstRejection) {
const err = new Error("partial failure");
err.partial = results;
err.cause = firstRejection;
throw err;
}
return results.map((r) => r.value);
}
Pattern 2 — use Promise.allSettled
allSettled is the right primitive when partial success is acceptable. It returns one descriptor per input — { status: 'fulfilled', value } or { status: 'rejected', reason }.
const results = await Promise.allSettled([
fetch("/api/a"),
fetch("/api/b"),
fetch("/api/c"),
]);
const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
const fail = results.filter((r) => r.status === "rejected").map((r) => r.reason);
console.log(`${ok.length} succeeded, ${fail.length} failed`);
Output:
2 succeeded, 1 failed
Cancellation — AbortController in depth
AbortController is the standard way to cancel a Promise-returning operation. The controller exposes a signal (an AbortSignal) that the operation observes; calling controller.abort(reason?) flips the signal's aborted flag and fires its abort event. Promise-based APIs like fetch, events.once, stream/promises.pipeline, and setTimeout/Promise integrate with it natively.
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("user cancelled")), 1000);
try {
const res = await fetch("/api/slow", { signal: controller.signal });
await res.json();
} catch (err) {
if (err.name === "AbortError") {
console.log("aborted:", err.cause?.message ?? err.message);
} else {
throw err;
}
}
Output:
aborted: user cancelled
Composing signals
Node 20 / Chrome 116 added AbortSignal.any([signals]) — the returned signal aborts as soon as any input signal does. Use it to combine a user-cancellation signal with a timeout signal.
const userCancel = new AbortController();
const combined = AbortSignal.any([
userCancel.signal,
AbortSignal.timeout(5000),
]);
await fetch("/api/data", { signal: combined });
Wrapping non-cancellable APIs
For Promise APIs that don't accept a signal, wrap them in a race against the signal's aborted Promise.
function withSignal(promise, signal) {
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
promise.then(resolve, reject);
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
}
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("timeout")), 100);
try {
await withSignal(somePromise(), controller.signal);
} catch (err) {
console.log("cancelled:", err.message);
}
Promise.race([promise, abortSignal])only wins the race — it does not stop the underlying work. The losing operation continues running and consuming resources. To stop the work itself, you must passsignalinto the operation when it supports cancellation, or accept the resource leak.
AggregateError
AggregateError is the error type thrown by Promise.any when every input rejects. Its .errors property is an array of the individual rejection reasons. It can also be constructed directly when you want to collect multiple errors from independent operations.
try {
await Promise.any([
Promise.reject(new Error("CDN A failed")),
Promise.reject(new Error("CDN B failed")),
Promise.reject(new Error("CDN C failed")),
]);
} catch (err) {
console.log(err instanceof AggregateError); // true
console.log(err.message); // "All promises were rejected"
for (const e of err.errors) console.log("-", e.message);
}
Output:
true
All promises were rejected
- CDN A failed
- CDN B failed
- CDN C failed
Constructing your own
function validate(data) {
const errors = [];
if (!data.email) errors.push(new Error("missing email"));
if (!data.name) errors.push(new Error("missing name"));
if (errors.length) {
throw new AggregateError(errors, "validation failed");
}
}
try {
validate({});
} catch (e) {
console.log(e.errors.map((x) => x.message));
}
Output:
[ 'missing email', 'missing name' ]
The deferred pattern (and why to avoid it)
A deferred is an object that exposes a Promise plus its resolve and reject functions so they can be invoked from outside the executor. It was common in pre-Promise libraries (jQuery's $.Deferred); modern JS provides it as Promise.withResolvers() since Node 22 / Chrome 119.
// Built-in since ES2024 (Node 22+, Chrome 119+, Safari 17.4+)
const { promise, resolve, reject } = Promise.withResolvers();
setTimeout(() => resolve("done"), 100);
console.log(await promise);
Output:
done
The deferred pattern is genuinely useful in two cases:
- Bridging callbacks to Promises — you have a callback API whose completion you want to expose as a Promise.
- Cross-function lifetimes — one function creates the Promise, another (later) resolves it. Common in event-driven code, queues, and FSMs.
If you can solve the problem with
new Promise((resolve, reject) => { … })and the executor function, do that — it scopes the resolvers tightly. The deferred pattern leaks resolvers into outer scope, where they can be lost or called twice without anyone noticing.
The classic anti-pattern — wrapping an already-Promise-returning API in a deferred:
// BAD — adds a layer of indirection, swallows stack traces
function getUserBad(id) {
const { promise, resolve, reject } = Promise.withResolvers();
fetch(`/api/users/${id}`).then((r) => r.json()).then(resolve, reject);
return promise;
}
// GOOD — return the chain directly
function getUserGood(id) {
return fetch(`/api/users/${id}`).then((r) => r.json());
}
Promise chaining and value propagation
A .then(onFulfilled) callback returns one of three things, each with a distinct effect on the next link in the chain:
- A plain value — becomes the next link's fulfillment value.
- A thrown error — becomes the next link's rejection reason (skips intermediate
thens until acatch). - Another Promise — the chain pauses until that Promise settles, then adopts its state and value.
Promise.resolve(1)
.then((v) => v + 1) // returns 2
.then((v) => Promise.resolve(v * 10)) // returns Promise<20>, chain pauses
.then((v) => { throw new Error(`bad ${v}`); }) // throws
.then((v) => console.log("never runs", v)) // skipped
.catch((err) => console.error("caught:", err.message));
Output:
caught: bad 20
Returning a Promise — flat, not nested
Returning a Promise from then does not produce Promise<Promise<T>>. The runtime "assimilates" (unwraps) thenables transparently. This is what makes .then().then().then() chains stay flat regardless of whether each callback returns a value or a Promise.
// Both look identical to the next link
Promise.resolve(1).then((v) => v + 1); // next sees 2
Promise.resolve(1).then((v) => Promise.resolve(v + 1)); // next sees 2 (after one tick)
.finally is transparent
.finally(cb) runs cb with no arguments and ignores its return value (unless cb throws, in which case the chain rejects with that error). The value passes through unchanged.
const v = await Promise.resolve(42).finally(() => {
console.log("cleanup");
return 999; // ignored
});
console.log(v); // 42
Output:
cleanup
42
Real-world recipes
Retry with exponential backoff
A Promise-returning operation that retries on rejection, doubling the delay between attempts, up to a max.
async function retry(fn, { attempts = 5, base = 200, factor = 2, maxDelay = 10_000 } = {}) {
let lastErr;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (i === attempts - 1) break;
const delay = Math.min(base * factor ** i, maxDelay);
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastErr;
}
const data = await retry(() => fetch("/api/data").then((r) => r.json()));
Promise pool with bounded concurrency
Promise.all fires every task immediately. For a list of N tasks where only K may run concurrently, schedule them into a fixed-size pool.
async function pool(items, limit, worker) {
const results = new Array(items.length);
let next = 0;
async function runOne() {
while (true) {
const i = next++;
if (i >= items.length) return;
results[i] = await worker(items[i], i);
}
}
await Promise.all(Array.from({ length: limit }, runOne));
return results;
}
const urls = Array.from({ length: 20 }, (_, i) => `/api/item/${i}`);
const data = await pool(urls, 5, async (url) => {
const r = await fetch(url);
return r.json();
});
console.log(data.length);
Output:
20
Promise-based queue
A FIFO queue of pending values, where consumers can await dequeue() and producers enqueue(value). Useful for cross-callback signalling.
function createQueue() {
const values = [];
const waiters = [];
return {
enqueue(value) {
if (waiters.length) {
waiters.shift().resolve(value);
} else {
values.push(value);
}
},
dequeue() {
if (values.length) return Promise.resolve(values.shift());
const { promise, resolve } = Promise.withResolvers();
waiters.push({ resolve });
return promise;
},
};
}
const q = createQueue();
q.enqueue("a");
setTimeout(() => q.enqueue("b"), 100);
console.log(await q.dequeue());
console.log(await q.dequeue());
Output:
a
b
Cancellable fetch with timeout
A reusable wrapper that aborts the network request after ms milliseconds.
function fetchWithTimeout(url, options = {}, ms = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error(`timeout after ${ms}ms`)), ms);
return fetch(url, { ...options, signal: controller.signal }).finally(() =>
clearTimeout(timer)
);
}
try {
const res = await fetchWithTimeout("/api/slow", {}, 2000);
await res.json();
} catch (err) {
if (err.name === "AbortError") console.log("aborted:", err.cause?.message);
}
Debouncing a Promise
Coalesce rapid-fire async calls so only the last one's Promise resolves. The earlier callers all receive the same final result.
function debouncePromise(fn, ms) {
let timer;
let pending;
return (...args) => {
clearTimeout(timer);
if (!pending) {
pending = Promise.withResolvers();
}
timer = setTimeout(async () => {
const p = pending;
pending = null;
try {
p.resolve(await fn(...args));
} catch (err) {
p.reject(err);
}
}, ms);
return pending.promise;
};
}
const search = debouncePromise(
(q) => fetch(`/api/search?q=${encodeURIComponent(q)}`).then((r) => r.json()),
300
);
await search("h");
await search("he");
await search("hel"); // only this one actually fires
Promise memoization
Cache the promise itself (not just the resolved value). Concurrent callers get the same in-flight Promise instead of triggering parallel work.
function memoize(fn) {
const cache = new Map();
return (key, ...rest) => {
if (!cache.has(key)) {
cache.set(key, fn(key, ...rest).catch((err) => {
cache.delete(key); // don't cache failures
throw err;
}));
}
return cache.get(key);
};
}
const fetchUser = memoize((id) => fetch(`/api/users/${id}`).then((r) => r.json()));
const [a, b, c] = await Promise.all([fetchUser(1), fetchUser(1), fetchUser(1)]);
// Only one network request was made.
Sequential reduce — fold an async pipeline
When each step depends on the previous result, fold them with a Promise-aware reducer.
const steps = [
async (input) => input.trim(),
async (input) => input.toUpperCase(),
async (input) => `[${input}]`,
];
const result = await steps.reduce(
(acc, step) => acc.then(step),
Promise.resolve(" hello ")
);
console.log(result);
Output:
[HELLO]