Using Promises
What is a Promise?
MDN: A Promise represents the eventual completion (or failure) of an asynchronous operation and its resulting value. In other words, it's a proxy for a value that isn't known yet when the promise is created.
In plain words: A promise is a placeholder for a value you don't have yet. You get the box back right away, and it fills in later — either with the result (success) or with an error (failure).
A promise doesn't do the async work
A promise is used for the result of an asynchronous task, but it doesn't make anything async and it doesn't run the task. The actual work (network, timer, file read) is done by the browser or Node the promise only holds the eventual result and notifies you when it's ready.
Think of it like a restaurant buzzer: the buzzer doesn't cook your food, the kitchen does. It just goes off when the food is ready. The promise is the buzzer, not the kitchen.
// A valid promise wrapping a value that was never async at all:
const p = Promise.resolve(42); // already "full", no background work
Promises are just a newer way — not the only way
A promise is not required to do async work. Async worked for years before promises existed, using callbacks. Promises are simply a nicer way to write the same thing — the browser/Node does the actual work either way.
The old way — callbacks (no promise)
A real API call, handled entirely with callbacks. No promise anywhere.
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/user");
xhr.onload = () => console.log(xhr.response); // runs when done
xhr.onerror = () => console.log("failed");
xhr.send();
The new way promises fetch
Same request, but fetch hands back a promise instead of taking a callback.
fetch("https://api.example.com/user")
.then(res => console.log(res)) // runs when done
.catch(() => console.log("failed"));
Both do the exact same async network call. XMLHttpRequest proves async API calls existed long before promises. What promises added is readability and clean error handling — especially once one async task depends on another (no more nesting, one .catch() for the whole chain).
Promises don't enable async — they organize it. Callbacks still work; promises just scale better as things get complex.
Callback hell → promise chain → async/await
The real reason promises exist is what happens when one async task depends on the result of another. With callbacks, that forces nesting, and the code drifts to the right — the "pyramid of doom."
Callback hell — nested and repetitive
getUser(id, (err, user) => {
if (err) return handleError(err);
getPosts(user, (err, posts) => {
if (err) return handleError(err); // error handling repeated...
getComments(posts, (err, comments) => {
if (err) return handleError(err); // ...at every single level
render(comments); // 3 levels deep, drifting right →
});
});
});
Two pains: rightward drift, and error handling copy-pasted at every level.
Promise chain — flat, one error drain
Each .then() returns a new promise, so the next .then() attaches at the same level. Read top-to-bottom, not left-to-right.
getUser(id)
.then(user => getPosts(user))
.then(posts => getComments(posts))
.then(comments => render(comments))
.catch(handleError); // ONE handler for the whole chain
async/await — reads like synchronous code
async/await is just sugar over promises. Same machine, no .then() noise, plain try/catch.
async function show() {
try {
const user = await getUser(id);
const posts = await getPosts(user);
const comments = await getComments(posts);
render(comments);
} catch (err) {
handleError(err); // normal try/catch handles any step
}
}
Promises only flatten things if you chain them. Nesting .then() calls instead of returning and chaining rebuilds the exact same pyramid — now with promises. Return the inner promise, chain at the same level.
| Approach | Callback hell? |
|---|---|
| Callbacks (nested) | Yes — the problem |
| Promises (chained) | No — flattened, if chained correctly |
| Promises (nested) | Yes — recreates the pyramid |
async/await | No — reads like sync code |
The three states & the settle-once guarantee
A promise is a state machine with exactly three states, and it's always in one of them:
| State | Meaning |
|---|---|
| pending | initial state — no value yet, still waiting |
| fulfilled | the operation succeeded; the box holds a value |
| rejected | the operation failed; the box holds a reason (an error) |
Every promise starts pending, then moves to either fulfilled or rejected — those are the only transitions:
┌──────────────► fulfilled (has a value)
pending ──┤
└──────────────► rejected (has a reason)
The one rule that matters most: when a promise leaves pending, it is settled, and that transition is permanent and irreversible. A fulfilled promise can never become rejected, and vice versa. It's a write-once box — the first write wins and freezes it.
const p = new Promise((resolve, reject) => {
reject(new Error("failed")); // runs FIRST → box latches as REJECTED
resolve("ok"); // ignored — already settled (silent no-op)
});
p.then(
(v) => console.log("fulfilled:", v), // fulfillment handler → skipped
(e) => console.log("rejected:", e.message), // rejection handler → runs
);
// => rejected: failed
The executor runs top to bottom, so whichever of resolve/reject fires first decides the state. Here reject wins; the later resolve("ok") is a no-op. .then's second argument is the rejection handler, so it runs.
It does not mean nothing happens or the promise stays pending. It means the first resolve/reject wins and freezes the state; every later settle attempt is ignored. There is always exactly one outcome, and a matching handler runs.
Two words you'll see everywhere:
- settled — fulfilled or rejected (i.e. "done, final"). Not a fourth state, just an umbrella for "no longer pending."
- resolved — "its fate is now locked in." Usually that means fulfilled, but resolving a promise to another promise leaves it resolved yet still pending, following the inner one. So resolved ≠ always fulfilled.
The constructor & the executor
You create a promise with new Promise(...), which takes exactly one argument, and it's required: a function called the executor.
The engine calls your executor immediately and hands it two levers of its own — resolve to fulfill the box, and reject to fail it. You don't create these; they write into the box using the settle-once machinery above.
new Promise((resolve, reject) => {
// ↑ ↑ engine-supplied; names are just convention
resolve(value); // → fulfill with value
reject(reason); // → reject with reason
});
The executor is mandatory
Leave it out and you get a TypeError on the spot — before any promise exists.
new Promise(); // ✗ TypeError: Promise resolver undefined is not a function
new Promise(() => {}); // ✓
The sharp edge — the executor runs synchronously
The executor body is not async. It runs the instant you call new Promise, before the constructor even returns. Only the .then callbacks are async.
console.log("A");
const p = new Promise((resolve) => {
console.log("B"); // runs NOW, during `new Promise`
resolve();
});
console.log("C");
// => A, B, C (not A, C, B)
So the myth that "code inside new Promise runs in the background" is false — a blocking loop in there would freeze the main thread like any other code.
Two consequences
1. A throw in the executor auto-rejects the promise
No need to call reject manually for errors:
const p = new Promise(() => {
throw new Error("boom"); // same as reject(new Error("boom"))
});
p.catch((e) => console.log("caught:", e.message)); // => caught: boom
But only before it settles — a throw after resolve() is swallowed by the write-once latch.
2. Wrapping a callback API is the only real reason to use new Promise
If you already have a promise, use .then/await; for an instant one, use Promise.resolve(x). The classic wrap:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// executor runs now → schedules the timer → resolve fires when it elapses
await wait(1000); // pause 1 second
The executor is required, runs synchronously on construction, and receives two engine-provided functions.
new Promiseexists to wrap non-promise APIs — nothing else.
Chaining — why .then returns a new promise
This is the most important mechanic in the whole system, and it fits in one line:
.then() does not modify the promise you call it on. It creates and returns a brand-new promise, whose fate depends on what your callback does:
const p2 = p.then(onFulfilled);
// p2 settles based on what onFulfilled does:
// returns a value v → p2 fulfills with v
// throws an error e → p2 rejects with e
// returns another promise → p2 adopts that promise's eventual state
Because every .then hands back a fresh promise, you can call .then on that, and so on. Each link is a transform station: a value flows in, a new promise carrying the transformed value flows out. That's why chains stay flat instead of nesting.
Promise.resolve(2)
.then((n) => n + 1) // returns 3 → next promise fulfills with 3
.then((n) => n * 10) // returns 30 → next promise fulfills with 30
.then((n) => console.log(n)); // => 30
The two mechanics people miss
If a callback returns a promise, the chain waits for it and passes the unwrapped value down — never a promise-of-a-promise:
Promise.resolve(1)
.then((n) => Promise.resolve(n + 1)) // returns a PROMISE
.then((n) => console.log(n)); // => 2 (auto-unwrapped, not Promise{2})
This is exactly why fetch(url).then((res) => res.json()) works — res.json() returns a promise, and the next .then receives the parsed data.
Note — .then callbacks run as microtasks. A callback that returns a plain value settles the next link in one tick; returning a promise costs extra ticks, because the engine must adopt and unwrap it. Same value, later arrival:
Promise.resolve(1).then((n) => n + 1).then(() => console.log("A done"));
Promise.resolve(1).then((n) => Promise.resolve(n + 1)).then(() => console.log("B done"));
Promise.resolve(1).then((n) => n + 1).then(() => console.log("C done"));
// => A done
// => C done
// => B done ← B returned a promise, so its unwrap got bumped to the back of the queue
return is load-bearingEvery .then passes down whatever its callback returns. Forget return and the next link gets undefined — the classic braces trap:
Promise.resolve(5)
.then((n) => n * 2) // arrow, implicit return → 10
.then((n) => { console.log("A:", n); }) // block body, no return → undefined
.then((n) => console.log("B:", n));
// => A: 10
// => B: undefined ← the braces {} swallowed the value
(n) => n * 2 returns; (n) => { n * 2 } does not. Adding a debug line and wrapping an arrow in braces is one of the most common ways to silently break a chain.
.thennever mutates — it returns a new promise carrying whatever the callback returned. Return a value → it flows down. Return a promise → it's awaited and unwrapped. Return nothing → the next link getsundefined.
Error handling — how rejections travel
A promise chain works like try/catch, stretched across time. A rejection skips over every .then fulfillment handler until it reaches a rejection handler — .catch, or the second argument of .then.
doSomething()
.then((a) => stepB(a)) // skipped if doSomething rejected
.then((b) => stepC(b)) // skipped too
.catch((err) => { // ← the rejection free-falls to here
console.error(err);
});
1. A throw inside a .then becomes a rejection. Synchronous throws and async rejections ride the same rail:
Promise.resolve()
.then(() => { throw new Error("boom"); }) // throw → rejection
.catch((e) => console.log("caught:", e.message)); // => caught: boom
2. .catch(fn) is literally .then(null, fn) — pure sugar, nothing more.
3. A .catch that doesn't re-throw recovers. The chain resumes fulfilled afterward, exactly like exiting a catch block:
Promise.resolve()
.then(() => { throw new Error("boom"); })
.catch(() => "recovered") // swallow → fulfill with "recovered"
.then((v) => console.log(v)); // => recovered (chain continues normally!)
To keep an error propagating past a .catch, you must re-throw inside it. Otherwise the next .then runs as if nothing went wrong.
Put one
.catchat the end of the chain, not a handler on every step. A rejection falls to the next rejection handler, so the natural place is the bottom.
The trap: .then(onFulfilled, onRejected) vs .then().catch()
The second argument of .then cannot catch an error thrown by the first argument of the same .then. They're siblings — the rejection handler only sees upstream rejections, never its own partner's throw.
Promise.resolve()
.then(
() => { throw new Error("in onFulfilled"); },
(err) => console.log("caught?", err), // ✗ NEVER runs — sibling, not a wrapper
);
// → unhandled rejection
Promise.resolve()
.then(() => { throw new Error("in onFulfilled"); })
.catch((err) => console.log("caught!", err.message)); // ✓ next link catches it
Rule of thumb: prefer a trailing .catch. Reach for the 2-arg form only when you deliberately want to handle an upstream rejection while ignoring errors from your own success handler.
Composition — running many at once
The four combinators take an array of promises and return a single new promise. They differ only in when they settle and on what.
| Combinator | Fulfills when | Rejects when | Use it for |
|---|---|---|---|
Promise.all | all fulfill → array of values | first rejection (fail-fast) | need every result; any failure aborts |
Promise.allSettled | all settle → array of {status, value/reason} | never | want every outcome, wins and losses |
Promise.race | first to settle (either way) | first settles as a rejection | timeouts; "whoever's fastest" |
Promise.any | first fulfillment | all reject (AggregateError) | first success; tolerate failures |
Concurrency doesn't come from the combinator — the array elements are already-running promises. The combinator only decides how to wait. Results keep input order, not completion order.
// All three start NOW, in parallel — before Promise.all is even called
const [a, b, c] = await Promise.all([fetch("/a"), fetch("/b"), fetch("/c")]);
// a is always the /a result, even if it finished last
all is all-or-nothing (first rejection kills the aggregate). allSettled never rejects — it reports every outcome.
await Promise.all([Promise.resolve(1), Promise.reject("boom"), Promise.resolve(3)]);
// → rejects with "boom" — you never see 1 or 3
await Promise.allSettled([Promise.resolve(1), Promise.reject("boom")]);
// => [{ status: "fulfilled", value: 1 }, { status: "rejected", reason: "boom" }]
race reacts to the first to settle (success or failure); any waits for the first to succeed, rejecting only if all fail. race cares about speed, any about success.
// race → timeout pattern
const withTimeout = (p, ms) =>
Promise.race([p, new Promise((_, reject) =>
setTimeout(() => reject(new Error("timeout")), ms))]);
// any → first success wins, ignores rejections
await Promise.any([Promise.reject("a"), Promise.resolve("b"), Promise.reject("c")]);
// => "b"
Promise.all rejecting does not cancel the other operations. They run to completion; their results are just discarded.
await Promise.all([
chargeCard(), // rejects fast → Promise.all rejects
sendConfirmation(), // STILL runs → email goes out anyway
reserveInventory(), // STILL runs → stock gets held anyway
]);
Fail-fast only affects when you stop waiting, not the underlying work. Promises can't cancel — they only observe. To actually stop the others, cancel them yourself (e.g. AbortController).
all= every result or bust ·allSettled= every outcome, always ·race= first to finish ·any= first to succeed.
Timing — microtasks vs macrotasks
Every ordering surprise in this doc comes from one fact: after each chunk of synchronous code, the event loop checks two separate queues.
| Queue | Who goes here | When it drains |
|---|---|---|
| Microtask | .then / .catch / .finally, await continuations, queueMicrotask | fully drained after each sync run, before any macrotask |
| Macrotask | setTimeout, setInterval, I/O, message events | one taken per loop iteration |
After the current sync code finishes, the engine empties the entire microtask queue, then takes one macrotask, then empties microtasks again, and so on. Microtasks always win.
console.log("1"); // sync
setTimeout(() => console.log("2"), 0); // macrotask
Promise.resolve().then(() => console.log("3")); // microtask
console.log("4"); // sync
// => 1, 4, 3, 2
Even setTimeout(..., 0) can't jump ahead of a microtask — 0 means "as soon as possible in the macrotask queue," which is still after every microtask.
setTimeout always produces a macrotask, even when called from inside a microtask. By then, earlier timers are already ahead of it.
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => {
console.log("C");
setTimeout(() => console.log("D"), 0); // macrotask, queued BEHIND B
});
Promise.resolve().then(() => console.log("E"));
console.log("F");
// => A, F, C, E, B, D
Sync → A, F. Drain microtasks → C, E (and D joins the macrotask queue behind B). One macrotask → B. Next macrotask → D.
Because microtasks drain completely first, a microtask that keeps queueing more microtasks blocks setTimeout forever.
function loop() {
Promise.resolve().then(loop); // each microtask schedules another
}
loop();
setTimeout(() => console.log("I never run"), 0); // starved
This is also why .then is always async, even on an already-settled promise: it schedules a microtask rather than running now. That guarantee (no "releasing Zalgo" — a callback that's sometimes sync, sometimes async) is what makes promises safe to reason about.
Scheduling location doesn't change the queue.
.then→ microtask (jumps ahead).setTimeout→ macrotask (waits its turn). Microtasks always fully drain before the next macrotask.
Cancellation — there isn't any (natively)
A promise has no .cancel() method, and never will. A promise is a view of an outcome, not the operation itself — the buzzer can't un-cook the food. The real work lives elsewhere (the network, the timer), so there's nothing on the promise to cancel.
const p = fetch("/big-file");
// p.cancel(); ✗ does not exist — a promise can't be cancelled
You cancel the operation, not the promise. The standard tool is AbortController: pass its signal into the async API, and .abort() stops the work and rejects the promise with an AbortError.
| Piece | Can you cancel it? |
|---|---|
| The promise | No — it's just the observer of an outcome |
| The work (fetch, timer) | Yes — stop it via AbortController |
| Effect of aborting | The promise rejects with AbortError |
A real example — cancelling a live fetch
Both versions hit jsonplaceholder.typicode.com, a free fake API that returns dummy JSON. Uncomment ac.abort() to see the cancel path fire instead of the data.
const ac = new AbortController();
fetch("https://jsonplaceholder.typicode.com/todos/1", { signal: ac.signal })
.then((res) => res.json()) // return the promise → next link gets the data
.then((data) => console.log(data)) // log HERE, inside the chain
.catch((err) => {
if (err.name === "AbortError") console.log("cancelled");
else throw err; // real errors still propagate
});
// ac.abort(); // → prints "cancelled" instead of the data
// => { userId: 1, id: 1, title: "delectus aut autem", completed: false }
const ac = new AbortController();
async function load() {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/todos/1", {
signal: ac.signal,
});
const data = await res.json(); // await the parse — it's a promise too
console.log(data);
} catch (err) {
if (err.name === "AbortError") console.log("cancelled");
else throw err;
}
}
load();
// ac.abort(); // → prints "cancelled" instead of the data
One AbortSignal is reusable — a single controller can cancel a fetch, a timer, and event listeners at once. And "cancelled" is just a rejection with a specific error name; there's no magic third state.
Promises can't be cancelled — they only observe. To "cancel," abort the underlying operation with
AbortController, and the promise rejects withAbortError. Filter for it in your error handler.
Gotcha table
| You might think… | What actually happens |
|---|---|
| resolve(a); resolve(b) ends up b | Stays a — write-once latch; later settles are no-ops |
| .then(f, g) — g catches f's throw | It doesn't — f and g are siblings, not wrapped |
| Returning a promise gives Promise<Promise> | You get the unwrapped value — chains auto-flatten thenables |
| n => ( n * 2 ) returns the value | Returns undefined — block braces need an explicit return |
| .then on a settled promise runs now | Runs as a microtask, after sync code — the no-Zalgo guarantee |
| setTimeout(…, 0) beats .then | .then wins — microtasks fully drain before any macrotask |
| Promise.all failing stops the rest | Others keep running, results dropped — fail-fast is aggregate-only |
| A promise can be cancelled | It can't — abort the work instead; a promise only observes |
| return Promise.resolve(x) vs return x | Same value, extra ticks — unwrapping a promise costs microtask hops |