Promises are the foundation everything async in JavaScript is built on —
fetch, async/await, dynamic import(). Most of us use them daily
while carrying a slightly fuzzy model of how they behave, and the fuzz
shows up as unhandled rejections, accidental sequential code, and .then
chains that silently swallow values. This post nails down the actual
rules. (For when promise callbacks run — microtasks and the event loop —
see the event loop post; this one is about
what they do.)
Three states, one transition
A promise settles exactly once — fulfilled or rejected — and never changes state again. Extra resolve/reject calls are silently ignored.
A promise is a state machine with exactly one move: from pending to either fulfilled (with a value) or rejected (with a reason). That transition happens once. Settle a promise twice and the second call is silently ignored:
const p = new Promise((resolve, reject) => {
resolve("first");
resolve("second"); // ignored
reject("nope"); // also ignored
});
This one-shot guarantee is the entire value proposition: a promise is a container you can hand to five different consumers, attach callbacks before or after it settles, and everyone observes the same immutable outcome. Compare that to a raw callback, which you just have to trust gets called exactly once. A buggy callback-based library can call you twice, or never, or call you synchronously sometimes and asynchronously other times; a promise rules out all three.
The chaining rules
.then doesn't just attach a callback — it returns a new promise,
whose fate is decided by what your callback does. There are only three
cases, and they're worth memorizing:
- Return a plain value → the new promise fulfills with that value.
- Return a promise → the new promise adopts it: waits for it and mirrors its outcome. This is why chains flatten instead of nesting.
- Throw → the new promise rejects with the thrown error.
fetch("/api/user")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); // rule 3
return res.json(); // rule 2 (a promise)
})
.then((user) => user.name) // rule 1 (a value)
.then((name) => console.log(name))
.catch((err) => console.error("somewhere above failed:", err));
Two practical corollaries:
- Forgetting
returnbreaks the chain. If a callback runssavePromisebut doesn't return it, the next.thenfires immediately withundefined, and errors from the orphaned promise escape the chain's.catchentirely. This is probably the most common promise bug in existence. - Nesting is a smell. Any time you write
.then(x => f(x).then(...))you almost certainly wanted to returnf(x)and chain at the top level.
How errors travel
A rejection skips forward past every fulfillment handler until it
meets a rejection handler — exactly like a thrown exception unwinding
past stack frames until a catch. And .catch(fn) is literally
.then(undefined, fn).
One subtlety worth knowing: .then(onFulfilled, onRejected) and
.then(onFulfilled).catch(onRejected) are not the same. The
two-argument form can't catch errors thrown by its own onFulfilled;
the chained .catch can, because it sits downstream of it.
After a .catch, the chain is recovered — whatever the catch handler
returns becomes a normal fulfillment for the next link. That's a feature
(fallback values) and a footgun (accidentally continuing a pipeline that
should have stopped). Re-throw if you only meant to log:
.catch((err) => {
reportError(err);
throw err; // keep the chain rejected
})
If nothing in the chain ever handles a rejection, the runtime raises
unhandledrejection (browsers) or, in Node, crashes the process by
default — promises make it loud when you drop errors on the floor.
The combinators
Four static methods coordinate multiple promises, differing along two axes: do you need all results or just one, and what should a failure do?
| Combinator | Resolves with | Rejects when | Use for |
|---|---|---|---|
| Promise.all | Array of all values (order preserved) | Any one rejects (fail fast) | Parallel work where every piece is required |
| Promise.allSettled | Array of { status, value | reason } | Never | Batch jobs where partial success is fine |
| Promise.race | First to settle — fulfilled or rejected | First settler rejected | Timeouts, "whichever responds first" |
| Promise.any | First to fulfill | All reject (AggregateError) | Redundant sources, fastest mirror wins |
The pattern that shows up constantly — independent requests should run
in parallel, and await makes it deceptively easy not to:
// Sequential by accident: ~600ms if each takes 200ms
const user = await fetchUser();
const posts = await fetchPosts();
const tags = await fetchTags();
// Parallel on purpose: ~200ms
const [user2, posts2, tags2] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchTags(),
]);
Each bare await parks the function until that promise settles, so three
independent fetches quietly serialize. The same trap wears a second
costume as await inside a loop — when iterations don't depend on each
other, build an array of promises and Promise.all it.
And race gives you timeouts in four lines:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}
(One honest caveat: losing promises aren't cancelled — the slow fetch
keeps running, you just stop waiting for it. Actual cancellation needs
AbortController.)
Concurrency with a limit
Promise.all over 10,000 items starts 10,000 requests at once — usually
a good way to get rate-limited, exhaust a connection pool, or run out of
file descriptors. What you usually want is "parallel, but at most N at a
time":
async function mapWithLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++; // safe: no await between read and increment
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
const pages = await mapWithLimit(urls, 5, (url) => fetch(url).then((r) => r.text()));
Five "workers" pull from a shared index until the list is empty. Results
keep input order, the first rejection rejects the whole call (like
Promise.all), and at most five requests are ever in flight. Because
JavaScript runs one callback at a time, next++ needs no lock — there's
no await between reading and incrementing it.
Turning callbacks into promises
Older APIs take a callback. Wrapping one is a one-time job:
function readFileAsync(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => (err ? reject(err) : resolve(data)));
});
}
For Node-style (err, value) callbacks, util.promisify does it for you,
and most of Node's core modules now ship promise versions anyway
(node:fs/promises, node:timers/promises). For event-style APIs where
the resolver needs to escape the executor, Promise.withResolvers() hands
you the promise and its resolve/reject together:
const { promise, resolve, reject } = Promise.withResolvers();
socket.once('ready', resolve);
socket.once('error', reject);
await promise;
Real cancellation
The race-based timeout above stops waiting, not working. For
fetch and many newer APIs, pass an AbortSignal, and the underlying
operation is actually cancelled:
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
// rejects with a TimeoutError after 5 s, and the request is torn down
Signals compose — AbortSignal.any([userCancel, AbortSignal.timeout(5000)])
aborts on whichever comes first — and you can accept a signal in your own
async functions and pass it down, so cancelling the top-level operation
cancels everything beneath it. See
AbortController and cancellation.
Unhandled rejections, in detail
A rejection is "unhandled" if no handler is attached by the time the microtask queue drains after it rejects. Two common ways to get one without noticing:
const p = fetchUser(); // starts, then rejects…
await sleep(1000); // …while nothing is listening
await p; // too late: already reported as unhandled
items.forEach(async (item) => { await save(item); }); // forEach ignores the promises
The first starts a promise and attaches a handler later; start it when
you're ready to await it, or attach a .catch immediately. The second
fires off promises nobody awaits — any rejection escapes, and the code
after the forEach runs before the saves finish. Use for...of with
await, or Promise.all(items.map(save)).
In Node, an unhandled rejection crashes the process by default. Register
a process.on('unhandledRejection', …) handler to log it with context —
but treat it as a bug report, not a recovery mechanism.
Common bugs at a glance
| you see | likely cause | fix |
|---|---|---|
| next .then gets undefined | missing return in the previous callback | return the promise or value |
| error not caught by .catch | an orphaned, unreturned promise inside a callback | return it so it joins the chain |
| code after a loop runs too early | async callback in forEach | for...of with await, or Promise.all(map) |
| slow page despite fast APIs | sequential awaits on independent work | Promise.all |
| 429s or pool exhaustion | unbounded Promise.all over a big list | a concurrency limit |
| timeout fires but work continues | race-based timeout | AbortSignal |
A promise in thirty lines
Writing a toy promise is the fastest way to make the three chaining rules stick, because the implementation is the rules:
class TinyPromise {
#state = 'pending'; #value; #handlers = [];
constructor(executor) {
const settle = (state) => (value) => {
if (this.#state !== 'pending') return; // one transition only
if (state === 'fulfilled' && value && typeof value.then === 'function') {
return value.then(settle('fulfilled'), settle('rejected')); // adopt
}
this.#state = state; this.#value = value;
this.#handlers.forEach((h) => queueMicrotask(h)); // callbacks are async
};
try { executor(settle('fulfilled'), settle('rejected')) }
catch (err) { settle('rejected')(err) }
}
then(onFulfilled, onRejected) {
return new TinyPromise((resolve, reject) => {
const run = () => {
const cb = this.#state === 'fulfilled' ? onFulfilled : onRejected;
if (typeof cb !== 'function') { // pass through
return (this.#state === 'fulfilled' ? resolve : reject)(this.#value);
}
try { resolve(cb(this.#value)) } // rules 1 and 2
catch (err) { reject(err) } // rule 3
};
if (this.#state === 'pending') this.#handlers.push(run);
else queueMicrotask(run);
});
}
catch(onRejected) { return this.then(undefined, onRejected) }
}
Every behaviour from this post is visible in it: settling twice is
ignored; then always returns a new promise; a returned value fulfils it,
a returned promise is adopted (via resolve's check for a .then), and a
throw rejects it; a missing handler passes the outcome through, which is
how rejections skip forward to the nearest catch; and callbacks always
run as microtasks, even if the promise is already settled. The real
specification adds edge cases — a promise resolved with itself, thenables
whose then throws — but not new ideas.
The "adopt anything with a .then method" step has a name: thenable
assimilation. It's why promises from different libraries, and objects
like query builders that define then, can all be awaited.
Odds and ends worth knowing
Promise.resolve(x)is idempotent — ifxis already a promise it returns it as-is, which makes it the standard way to normalize "value-or-promise" inputs.- The executor runs synchronously —
new Promise(fn)callsfnimmediately; only the.thencallbacks are deferred (to the microtask queue). .finallypasses through — it sees no value and returns the chain's outcome unchanged; perfect for hiding spinners regardless of result.asyncfunctions are promise factories —return xfulfills,throwrejects, and callers compose them with the same combinators.allSettledresults need reading. It never rejects, so failures are easy to ignore. Filter forstatus === 'rejected'and log or retry them, or you've built a batch job that silently drops errors.AggregateErrorfromPromise.anycarries every rejection in itserrorsarray — useful when all mirrors failed and you need to say why.- Don't mix styles in one chain. Code that awaits some promises and
.thens others is harder to follow than either style alone; in modern code,awaitwithtry/catchis the default, and.thenearns its place in small one-line transformations. See async/await under the hood.
The mental model that ties it together: a promise chain is a pipeline of
one-shot containers, each link deciding the next one's fate by value,
promise, or throw — with rejections riding an express lane to the nearest
catch. Every confusing chain becomes legible once you trace it link by
link with those three rules. And when a chain is too tangled to trace, that's
usually the signal to rewrite it with await — the rules don't change,
but the control flow becomes ordinary code again.