Chapter 3 — Promises in Real Code
The state machine, floating promises, lost errors, and choosing a combinator
Prerequisites: Chapter 1 (microtasks), Chapter 2 (what promises replaced), FE-10 Part 8 (what a promise object is). Practice: the gym, exercises 06-cache-layer and 10-polyfill-kata.
How to Read This Chapter
A promise fixes the callback bugs from Chapter 2 by design: it settles once, and its handlers never run synchronously. What it adds are bugs of disconnection. A promise that nobody returns, nobody awaits, or nobody catches still runs, still fails, and in Node 15+ still kills the process. Most promise bugs in review come down to one question: is this promise connected to the chain that the caller is waiting on?
The chapter ends with a full Promises/A+ implementation. Writing one is a common interview round, and it is also the fastest way to make the rules in Parts 1–7 feel obvious.
| Term | Meaning |
|---|---|
| Pending / fulfilled / rejected | The three states. Fulfilled and rejected are settled, and a settled promise never changes |
| Resolved | Locked in: either settled, or following another promise or thenable. A resolved promise can still be pending |
| Reaction | A handler registered with .then, queued as a microtask when the promise settles |
| Thenable | Any object or function with a then method. Promises treat thenables as promises |
| Assimilation | A promise adopting the eventual state of a thenable it was resolved with |
| Floating promise | A promise that nobody returns, awaits or catches. Its result and its errors go nowhere |
| Unhandled rejection | A promise rejected with no handler attached by the time the host checks |
| Combinator | Promise.all, allSettled, race, any: one promise from many |
Part 1: The State Machine
The "following" state is what the spec calls resolved but pending. It explains three behaviours you will be asked about:
new Promise((resolve) => resolve(Promise.reject(err)))rejects.resolvedoes not mean "fulfil". It means "lock in to this", and it locked in to a rejection.- After
resolve(p)is called, later calls toresolveorrejectare ignored, even though the promise is still pending. - Resolving with a thenable costs extra microtask hops, because the promise has to call
thenable.thenin a job of its own (Chapter 1, Part 2).
Each .then(onFulfilled, onRejected) returns a new promise. That new promise is decided by what the handler does:
| The handler… | The returned promise… |
|---|---|
returns a value v | fulfils with v |
returns a promise or thenable p | follows p |
throws e | rejects with e |
| is missing for this outcome | takes the same outcome as the previous promise (pass-through) |
That last row is how one .catch at the end of a chain catches a rejection from any step above it: each .then without a rejection handler passes the rejection down.
Part 2: The .then That Forgets to return
function chargeForStory(userId, cost) {
return db.getWallet(userId)
.then((wallet) => {
db.charge(wallet, cost); // starts the charge, returns nothing
})
.then(() => res.json({ status: 'charged' }));
}
The braces turn the arrow function into a block body, so it returns undefined. The second .then fulfils at once, and the charge runs detached from the chain. Run in Node with a fake 20 ms charge:
cost 30:
route: sending 200 "charged"
db: charged 30
cost 500:
route: sending 200 "charged"
unhandledRejection: insufficient
The fix is one word, and the reason is the table in Part 1: return the inner promise so the next step follows it.
.then((wallet) => db.charge(wallet, cost)) // concise body returns the promise
The same bug inside async code is a missing await (Chapter 4). The lint rule that catches both is @typescript-eslint/no-floating-promises; in plain JS projects, look for .then( with a { body and no return.
Part 3: The Explicit-Construction Antipattern
function getUser(id) {
return new Promise((resolve, reject) => {
fetchUser(id).then((user) => resolve(normalize(user)));
});
}
This wraps a promise in a new promise for no reason, and it breaks error handling twice:
- If
fetchUserrejects, nothing callsreject. The outer promise stays pending forever, and the inner rejection is unhandled. - If
normalizethrows, it throws inside a.thenhandler, which rejects the inner chain, not the outer promise. Same result: pending forever plus an unhandled rejection. (Verified: withnormalizethrowing on anullname,getUser(1)was still pending 100 ms later and Node reported theTypeErroras unhandled.)
The executor's built-in try/catch does not help, because both failures happen after the executor returned. The fix is to return the chain you already have:
function getUser(id) {
return fetchUser(id).then(normalize);
}
// or
async function getUser(id) {
return normalize(await fetchUser(id));
}
new Promise is for wrapping things that are not promises: callback APIs, events, timers. If the executor's body calls a function that already returns a promise, that is the antipattern.
Part 4: Swallowed Errors and Where .catch Goes
Two placements look equivalent and are not:
op().then(onOk, onFail); // onFail handles op() failing only
op().then(onOk).catch(onFail); // onFail handles op() failing OR onOk throwing
With op resolving { items: null } and onOk reading r.items.length: in the first form the TypeError from onOk escapes onFail and goes to whatever is next. In the second, onFail sees it.
The more common bug is a .catch that logs and carries on:
op()
.then((r) => r.items.length)
.catch((err) => { log.error(err); }) // returns undefined → fulfilled
.then((count) => billing.record(count)); // runs with count = undefined
A .catch handler is an ordinary handler: if it returns normally, the chain recovers. Code after it runs as if nothing failed, with undefined. Decide which of three things you mean, and write that:
| Intent | Write | Effect downstream |
|---|---|---|
| Recover with a real fallback | .catch(() => CACHED_DEFAULT) | Continues with a usable value |
| Add context and keep failing | .catch((err) => { throw new Error('usage lookup failed', { cause: err }); }) | Still rejected, now with a better message |
| Log and keep failing | .catch((err) => { log.error(err); throw err; }) | Still rejected. Prefer logging once, at the top |
Where to put the final .catch: at the boundary that can do something about the failure. That is the route handler that turns it into a 500, the job runner that marks the job failed, the UI that shows an error. Catching in the middle of a chain is right only when that step has a real fallback.
Part 5: Unhandled Rejections
When a promise rejects and has no handler, the host notices. What it does differs:
| Host | Default behaviour |
|---|---|
| Browser | Fires unhandledrejection on window; logs "Uncaught (in promise)". The page keeps running |
| Node ≤ 14 | Printed a warning and kept running |
| Node 15+ | --unhandled-rejections=throw: emits unhandledRejection; if no listener exists, raises it as an uncaught exception. The process exits with code 1 |
The timing matters. Node checks after the microtask queue drains. A handler attached later in the same drain is fine; a handler attached in a later task is too late:
const p = Promise.reject(new Error('provider 503'));
await null;
await null;
p.catch((e) => console.log('caught:', e.message)); // fine: same drain
const q = Promise.reject(new Error('provider 503'));
setTimeout(() => q.catch(() => {}), 0); // too late: the process has already exited
The pattern that hits this in real code is starting two operations and awaiting them one by one:
async function buildDashboard(userId) {
const profileP = getProfile(userId); // 50 ms
const usageP = getUsage(userId); // fails after 10 ms
try {
const profile = await profileP;
const usage = await usageP;
return { profile, usage };
} catch (err) {
return { error: 'dashboard unavailable' };
}
}
Fix with await Promise.all([profileP, usageP]), or allSettled if a partial dashboard is acceptable.
What a process-level handler should do: log and exit, not swallow. process.on('unhandledRejection', (err) => { log.fatal(err); process.exit(1); }) adds logging; a handler that only logs turns crashes into silent corruption. Chapter 8 covers shutdown.
Part 6: finally Semantics
p.finally(fn) is for cleanup. It is not p.then(fn, fn):
| Code | Result | Rule |
|---|---|---|
Promise.resolve(1).finally(() => 2) | fulfils with 1 | The return value is ignored |
Promise.reject(e).finally(() => 'x') | rejects with e | The outcome passes through |
Promise.resolve(1).finally(() => { throw err2; }) | rejects with err2 | A throw replaces the outcome |
Promise.reject(e).finally(() => Promise.reject(err2)) | rejects with err2 | A rejected returned promise replaces it too |
p.finally(() => sleep(30)) | settles 30 ms later, with p's outcome | A returned promise is waited for |
p.finally((x) => …) | x is undefined | finally gets no argument |
The waiting row is a feature: .finally(() => client.release()) holds the chain until the connection is back in the pool. The throwing row is a trap: if release() throws, it replaces the original error, and the log shows "release failed" instead of the query error that caused it.
try/finally inside an async function follows ordinary JS rules, which differ: a return inside finally overrides the try's return value or thrown error. async () => { try { return 'try'; } finally { return 'finally'; } } resolves with 'finally'. Never return from a finally block.
Part 7: Thenables and Assimilation
Any object with a then method is treated as a promise by Promise.resolve, await, return from an async function, and every combinator.
const t = { then(resolve) { console.log('then() called'); resolve(42); } };
Promise.resolve(t).then((v) => console.log('assimilated', v));
console.log('sync end');
// sync end, then() called, assimilated 42
The then method is called in a later microtask, never synchronously. The rules for a thenable are defensive, because it may be badly written:
- Only the first call to
resolveorrejectcounts. - A throw from
thenafter it calledresolveis ignored. - The
thenproperty is read once, because it may be a getter with side effects. Promise.resolve(p) === pwhenpis already a native promise; no new wrapper.
The review bug is accidental thenables. Query builders such as Knex and Mongoose queries are thenables on purpose: awaiting the builder runs the query. Returning a builder from an async function therefore runs it:
async function queuedJobsQuery() {
return knex('jobs').where({ status: 'queued' }); // caller wanted a builder to add .limit()
}
const q = await queuedJobsQuery(); // q is the rows; the query already ran without a limit
The same happens with any class that grows a then method, or a module that exports a function named then, which changes what await import() returns. To hand back a thenable unexecuted, wrap it: return { query: knex('jobs') }.
Part 8: Choosing a Combinator
| Combinator | Settles when | Result | Use it for |
|---|---|---|---|
Promise.all | All fulfil, or the first rejects | Array of values in input order | Everything is required: profile + wallet + plan |
Promise.allSettled | All settle | [{ status, value | reason }] | Partial results are fine: a fan-out to 10 webhooks |
Promise.race | The first settles, either way | That value or reason | Timeouts; first response wins, including failures |
Promise.any | The first fulfils, or all reject | First value, or AggregateError | Redundant sources: the first healthy LLM provider |
Edge cases interviewers check: all([]) fulfils with []; allSettled([]) fulfils with []; any([]) rejects with an AggregateError whose errors is empty; race([]) stays pending forever. Non-promise items are treated as already fulfilled, so race([slowPromise, 'plain']) returns 'plain'.
AggregateError has an errors array in input order, not in the order they failed. For a provider failover with any, log every entry: "All promises were rejected" alone tells you nothing.
No combinator cancels anything. When Promise.all rejects, the other operations keep running and their results are thrown away. When race picks a winner, the loser keeps running. (Verified: after all rejected at 10 ms, the 50 ms operation still finished.) For LLM calls, that means you still pay for the losing request. Pass an AbortSignal and abort the losers (Chapter 6).
Promise.withResolvers() (Node 22, all current browsers) returns { promise, resolve, reject }, for when the code that settles a promise is not inside its executor: resolve a waiting request from a webhook handler, or build a queue whose consumers wait on a promise that producers resolve.
const waiting = new Map(); // jobId -> resolve
export function waitForRender(jobId) {
const { promise, resolve } = Promise.withResolvers();
waiting.set(jobId, resolve);
return promise; // add a timeout in real code; see Spot the Bug, Drill 2
}
app.post('/webhooks/render', (req, res) => {
waiting.get(req.body.jobId)?.(req.body);
waiting.delete(req.body.jobId);
res.sendStatus(204);
});
Spot the Bug
Drill 1: the embedding cache
const embeddings = new Map(); // text -> Promise<number[]>
export function getEmbedding(text) {
if (!embeddings.has(text)) {
embeddings.set(text, provider.embed(text));
}
return embeddings.get(text);
}
Caching the promise, not the value, is deliberate: two concurrent callers share one provider call. What's wrong?
Answer
Bug. A rejected promise is cached forever. If the provider returns a 503 once, every later call for that text gets the same rejected promise and the provider is never asked again. (Verified: three attempts, three failures, one provider call.) The service fails until the process restarts, and the cache has no TTL or size limit, so it also grows without bound.
Interleaving. Call 1 misses, stores the pending promise → provider fails → promise rejects, still in the map → call 2 hits, returns the rejected promise → call 3 the same.
Fix. Evict on rejection, but only if the entry is still the one you stored (a newer call may have replaced it):
export function getEmbedding(text) {
let p = embeddings.get(text);
if (!p) {
p = provider.embed(text);
embeddings.set(text, p);
p.catch(() => {
if (embeddings.get(text) === p) embeddings.delete(text);
});
}
return p;
}
A side effect worth knowing: the p.catch attaches a handler, so this promise can no longer trigger an unhandled rejection. Callers that await it still receive the rejection. Add an LRU bound and a TTL for real use. Gym exercise 06-cache-layer has this bug.
Say it: "Caching the promise gives you single-flight, but you also cache the failure. Evict on rejection, and compare identity before deleting."
Drill 2: the timeout helper
export function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
}
// used as
const reply = await withTimeout(llm.complete(prompt), 30_000);
What's wrong? There are two problems.
Answer
Bug 1: the timer is never cleared. When promise wins, the 30-second timer stays scheduled. Under load you hold thousands of timers; in a CLI or a test, the process cannot exit until each one fires. (Verified: a 10 ms success with a 2000 ms timeout kept Node alive for 2 s.) When it fires, it rejects a promise nobody listens to. That is harmless here only because race already attached a handler.
Bug 2: the timeout doesn't stop the work. When the timer wins, llm.complete keeps running. You pay for the tokens, hold a socket, and maybe write the late result somewhere.
Fix.
export function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${ms} ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
// better: let the operation itself stop
const reply = await llm.complete(prompt, { signal: AbortSignal.timeout(30_000) });
AbortSignal.timeout rejects with a TimeoutError and the client cancels the HTTP request (Chapter 6).
Say it: "race picks a winner but cancels nothing: clear the timer in finally, and pass an AbortSignal so the slow call actually stops."
Drill 3: the provider wrapper
function callProvider(prompt) {
return openai.complete(prompt)
.catch((err) => {
log.warn('openai failed, will fall back', err);
})
.then((res) => res.text);
}
export async function answer(prompt) {
try {
return await callProvider(prompt);
} catch (err) {
return anthropicFallback(prompt, err);
}
}
The failover "works", but the on-call engineer sees TypeError: Cannot read properties of undefined (reading 'text') in every incident and never the real provider error. What's wrong?
Answer
Bug. The .catch logs and returns undefined, so the chain recovers. The next .then runs with res = undefined and throws a TypeError. The fallback runs, but the error it receives is the TypeError, not the 429 or 503 from OpenAI. The decision "should we fall back for this kind of error?" is now impossible, because every error looks the same.
Fix. Put the .catch after the step it should guard, or rethrow:
function callProvider(prompt) {
return openai.complete(prompt).then((res) => res.text);
}
and let answer decide. If you want a log line at this level, rethrow with context: throw new Error('openai failed', { cause: err }).
Say it: "A catch that doesn't rethrow turns a rejection into a fulfilment with undefined. The real error is replaced by a TypeError one step later."
Drill 4: what happens?
const results = [];
const p = new Promise((resolve, reject) => {
resolve(Promise.reject(new Error('A')));
resolve('B');
reject(new Error('C'));
});
p.then((v) => results.push(v), (e) => results.push(e.message))
.finally(() => results.push('done'))
.then(() => console.log(results));
Answer
Prints [ 'A', 'done' ].
The first resolve locks the promise to Promise.reject(new Error('A')), so p is resolved but still pending. The later resolve('B') and reject(C) are ignored. When the inner promise's rejection is adopted, p rejects with A. The rejection handler pushes 'A' and returns normally, so the chain is fulfilled; finally runs and passes that through.
Say it: "resolve means lock in, not fulfil. The first call wins even though the promise is still pending at that moment."
Build It: MyPromise, Promises/A+ Style
Spec.
new MyPromise(executor)callsexecutor(resolve, reject)synchronously. A throw from the executor rejects, unlessresolveorrejectwas already called.- Only the first call to
resolveorrejectcounts. resolve(x)runs the resolution procedure:x === thisrejects with aTypeError; an object or function with a callablethenis adopted (readthenonce; the first of its callbacks wins; a throw after a callback is ignored); anything else fulfils.then(onFulfilled, onRejected)returns a new promise. Handlers run in a later microtask (queueMicrotask), in registration order. A missing handler passes the outcome through. A handler's return value goes through the resolution procedure; a throw rejects.catch,finally(with the semantics in Part 6),MyPromise.resolve,MyPromise.reject.all,allSettled,race,anyaccept any iterable, keep input order, and treat non-promises as fulfilled values.
Reference solution
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
export class MyPromise {
#state = PENDING;
#value = undefined;
#reactions = []; // handlers registered while pending
constructor(executor) {
if (typeof executor !== 'function') {
throw new TypeError('MyPromise executor must be a function');
}
const { resolve, reject } = this.#createResolvingFunctions();
try {
executor(resolve, reject);
} catch (err) {
reject(err); // ignored if resolve/reject already ran
}
}
// A resolve/reject pair that shares one "already resolved" flag.
// Only the first call of either function counts.
#createResolvingFunctions() {
let alreadyResolved = false;
return {
resolve: (x) => {
if (alreadyResolved) return;
alreadyResolved = true;
this.#resolveWith(x);
},
reject: (reason) => {
if (alreadyResolved) return;
alreadyResolved = true;
this.#settle(REJECTED, reason);
},
};
}
// The Promise Resolution Procedure: [[Resolve]](promise, x)
#resolveWith(x) {
if (x === this) {
this.#settle(REJECTED, new TypeError('Chaining cycle detected for promise'));
return;
}
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
let then;
try {
then = x.then; // read exactly once: it may be a getter
} catch (err) {
this.#settle(REJECTED, err);
return;
}
if (typeof then === 'function') {
// Adopt the thenable's eventual state. A fresh pair: the thenable
// may call them several times or throw after calling one.
const { resolve, reject } = this.#createResolvingFunctions();
try {
then.call(x, resolve, reject);
} catch (err) {
reject(err);
}
return;
}
}
this.#settle(FULFILLED, x);
}
#settle(state, value) {
if (this.#state !== PENDING) return;
this.#state = state;
this.#value = value;
const reactions = this.#reactions;
this.#reactions = [];
for (const reaction of reactions) this.#runLater(reaction);
}
// Handlers always run in a later microtask, never synchronously.
#runLater({ onFulfilled, onRejected, resolve, reject }) {
queueMicrotask(() => {
const fulfilled = this.#state === FULFILLED;
const handler = fulfilled ? onFulfilled : onRejected;
if (typeof handler !== 'function') {
// No handler for this outcome: pass it down the chain unchanged.
if (fulfilled) resolve(this.#value);
else reject(this.#value);
return;
}
let result;
try {
result = handler(this.#value);
} catch (err) {
reject(err);
return;
}
resolve(result); // runs the resolution procedure on the next promise
});
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const reaction = { onFulfilled, onRejected, resolve, reject };
if (this.#state === PENDING) this.#reactions.push(reaction);
else this.#runLater(reaction);
});
}
catch(onRejected) {
return this.then(undefined, onRejected);
}
finally(onFinally) {
if (typeof onFinally !== 'function') return this.then(onFinally, onFinally);
return this.then(
(value) => MyPromise.resolve(onFinally()).then(() => value),
(reason) => MyPromise.resolve(onFinally()).then(() => { throw reason; }),
);
}
static resolve(x) {
if (x instanceof MyPromise) return x;
return new MyPromise((resolve) => resolve(x));
}
static reject(reason) {
return new MyPromise((_, reject) => reject(reason));
}
static all(iterable) {
return new MyPromise((resolve, reject) => {
const results = [];
let remaining = 0;
let index = 0;
for (const item of iterable) {
const i = index++;
remaining++;
MyPromise.resolve(item).then((value) => {
results[i] = value;
if (--remaining === 0) resolve(results);
}, reject);
}
if (remaining === 0) resolve(results); // empty input
});
}
static allSettled(iterable) {
return new MyPromise((resolve) => {
const results = [];
let remaining = 0;
let index = 0;
for (const item of iterable) {
const i = index++;
remaining++;
MyPromise.resolve(item).then(
(value) => {
results[i] = { status: 'fulfilled', value };
if (--remaining === 0) resolve(results);
},
(reason) => {
results[i] = { status: 'rejected', reason };
if (--remaining === 0) resolve(results);
},
);
}
if (remaining === 0) resolve(results);
});
}
static race(iterable) {
return new MyPromise((resolve, reject) => {
for (const item of iterable) {
MyPromise.resolve(item).then(resolve, reject);
}
// Empty input: stays pending forever, like the native one.
});
}
static any(iterable) {
return new MyPromise((resolve, reject) => {
const errors = [];
let remaining = 0;
let index = 0;
for (const item of iterable) {
const i = index++;
remaining++;
MyPromise.resolve(item).then(resolve, (reason) => {
errors[i] = reason;
if (--remaining === 0) reject(new AggregateError(errors, 'All promises were rejected'));
});
}
if (remaining === 0) reject(new AggregateError(errors, 'All promises were rejected'));
});
}
}
Verified in Node 22 with 56 test cases. Most of them run the same scenario against native Promise and MyPromise and compare the outcome: pass-through, recovery, throwing handlers, adopting native promises and sync/async/nested thenables, thenables that call both callbacks or throw before or after, a throwing then getter, functions as thenables, the cycle TypeError, every finally row from Part 6, and every combinator including empty input, Set and string iterables, non-iterables, and AggregateError.errors order. Also checked: handlers never run synchronously, the A F C E D B ordering from Chapter 1 matches native, await works on a MyPromise, and a 100,000-step chain and a 10,000-deep nested resolve complete without a stack overflow.
Edge cases interviewers check.
| Case | Expected | What breaks if you get it wrong |
|---|---|---|
then on an already-settled promise | Handler still runs async | Zalgo returns (Chapter 2) |
Executor calls resolve then throws | Stays fulfilled | A settled promise changes state |
p.then(() => p2) where p2 is that same returned promise | Rejects with TypeError | Hangs forever |
resolve(thenable) | Adopts it; result is not the thenable object | Nested promises leak to callers |
then getter with side effects | Read once | Double execution |
then(undefined, undefined) | Pass-through | Values or errors vanish mid-chain |
finally handler | Gets no argument; return value ignored unless it throws or rejects | Cleanup changes results |
all with results arriving out of order | Input order | Results paired with the wrong request |
any when all reject | AggregateError with errors in input order | Error details lost |
race([]) | Pending forever | — (know it, don't "fix" it) |
Known differences from native promises, worth saying out loud: this version calls a thenable's then synchronously inside resolve (Promises/A+ allows it; native promises defer it by one job, so ordering around thenables differs by a tick); it has no unhandled-rejection tracking; and it ignores subclassing (Symbol.species).
The Causal Chains
- If a
.thenhandler starts a promise but doesn't return it - If
new Promisewraps a call that already returns a promise - If a
.catchlogs without rethrowing - If a rejected promise has no handler when Node checks after the microtask drain
- If you start two promises and
awaitthem one after the other - If a
finallycallback throws - If an object has a
thenmethod - If
Promise.allrejects orracesettles
Review Checklist
.then((x) => { … })with a block body and noreturn. The classic floating promise.new Promisewhose executor calls a promise-returning function. Replace with the chain..catchhandlers that don't rethrow or return a real fallback. What does the next step receive?.then(ok, fail)wherefailis expected to cover errors fromok.- Promises started early and awaited later, one at a time. Each is an unhandled-rejection window.
process.on('unhandledRejection')handlers that log and continue.finallyhandlers that can throw, andreturninside afinallyblock.- Promise caches with no eviction on rejection.
Promise.racetimeouts that never clear the timer or abort the loser.Promise.allover work where partial success is acceptable (should beallSettled), or over unbounded arrays (should be a pool, Chapter 7).
What's Next
async/await is promises with nicer syntax, and it keeps every rule in this chapter while hiding most of them. Chapter 4 — async/await Traps covers the missing await, forEach with async, sequential awaits that should be parallel, and return vs return await.