A promise is an object that represents a future value — a value you don't have yet, but the runtime promises (hence the name) will arrive eventually, or fail with an error. Promises replaced the callback pyramid with a chain you can read top to bottom. They're also the foundation async/await is built on, so even though you'll write await more often than .then, knowing promises is non-negotiable.
The mental model — a paper receipt
Order food at a busy restaurant. The cashier hands you a paper receipt with a number. You don't have your meal yet. But the receipt is real — you can hold it, queue with it, look at it. Eventually one of two things happens:
- The kitchen calls your number. You collect your meal. (The promise resolves.)
- The kitchen calls your number with an apology — they're out of an ingredient. (The promise rejects.)
A promise object lives in one of three states:
- Pending — no answer yet. The receipt is sitting on the counter.
- Fulfilled (also called resolved) — the work succeeded. The promise carries a value.
- Rejected — the work failed. The promise carries an error.
Once a promise leaves the pending state, it never goes back. It settles to fulfilled or rejected exactly once.
Creating a promise (briefly)
You'll rarely write new Promise(...) yourself in test code — most APIs already return promises (fetch, fs.promises.readFile, page.click in Playwright). But seeing one constructed clarifies the model.
const flakyTask = new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.5) {
resolve("success!");
} else {
reject(new Error("randomly failed"));
}
}, 500);
});new Promise accepts a function that gets two callbacks: resolve (call to fulfill the promise) and reject (call to reject it). After 500ms, the task either succeeds or fails — and the promise transitions to that state.
You don't need to memorise this. What matters is what the consumer does with the promise.
Consuming a promise — .then and .catch
A consumer attaches handlers with .then (for success) and .catch (for failure):
flakyTask
.then(value => console.log("Got:", value))
.catch(error => console.error("Failed:", error.message));Whichever of the two states the promise lands in, exactly one of those handlers runs. The other is silent.
A real example — fetch
Browsers and modern Node.js ship fetch, which makes an HTTP request and returns a promise.
fetch("https://api.staging.com/users")
.then(response => response.json()) // also returns a promise
.then(users => console.log(`Loaded ${users.length} users`))
.catch(error => console.error("Fetch failed:", error.message));Output (when the API works):
Loaded 42 users
Read top to bottom: fetch the URL, parse the response as JSON, log the count. If anything along the chain fails — network error, bad JSON, missing field — the single .catch at the end handles it.
Chaining solves callback hell
The pyramid from the previous lesson, re-expressed as a promise chain:
const fs = require("node:fs/promises"); // promise-based fs
fs.readFile("config.json", "utf-8")
.then(text => JSON.parse(text))
.then(config => fs.readFile(config.usersPath, "utf-8"))
.then(text => JSON.parse(text))
.then(users => users.filter(u => u.role === "admin"))
.then(admins => fs.writeFile("admins.json", JSON.stringify(admins, null, 2)))
.then(() => console.log("All done"))
.catch(error => console.error("Pipeline failed:", error.message));Each .then returns a new promise — that's why you can keep chaining. The whole sequence reads as a vertical pipeline. One .catch at the bottom captures errors from any step; if step 3 fails, step 4 doesn't run.
Compare to four levels of nested callbacks. Same logic, dramatically clearer structure. This is the promise win.
Returning a promise from .then
A subtle but important rule: if a .then handler returns a promise, the chain waits for that promise before running the next .then. That's how you sequence async work.
fetch("/api/login")
.then(res => res.json()) // promise unwrapped automatically
.then(({ token }) => fetch("/api/profile", { // returns a fetch promise
headers: { Authorization: `Bearer ${token}` }
}))
.then(res => res.json()) // continues with the second response
.then(profile => console.log(profile.name))
.catch(err => console.error(err));Each .then sees the resolved value of the previous step's promise, even when the previous step itself returned a promise. The chain "flattens" automatically — no nesting required.
.finally — cleanup either way
Sometimes you want code that runs regardless of success or failure — closing a connection, resetting a flag, logging "done" to a metrics dashboard. .finally runs after either .then or .catch:
fetch("/api/health")
.then(res => console.log("Status:", res.status))
.catch(err => console.error("Down:", err.message))
.finally(() => console.log("Health check finished"));.finally doesn't receive the value or the error — its job is purely the side effect.
Running things in parallel — Promise.all
When you have multiple independent async operations, running them sequentially wastes time. Promise.all runs them in parallel and waits for all of them to finish.
Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/products").then(r => r.json()),
fetch("/api/orders").then(r => r.json())
])
.then(([users, products, orders]) => {
console.log(`${users.length} users, ${products.length} products, ${orders.length} orders`);
})
.catch(error => console.error("One of the calls failed:", error));The three fetches start at once. Promise.all resolves to an array of results in the same order as the input promises — array destructuring (chapter 4) pulls them apart. If any of the promises rejects, the whole thing rejects.
For "wait for all but don't fail on individual errors," there's Promise.allSettled — it returns an array of { status, value } or { status, reason } objects so you can inspect each independently.
A promise chain in action
Step 1 of 5
Pending — fetch dispatched
fetch('/api/users') returns a promise immediately. The HTTP call is in flight.
⚠️ Common mistakes
- Forgetting to return inside
.then..then(res => { res.json(); })callsres.json()but doesn't return its promise. The chain doesn't wait, and the next.thengetsundefined. Either drop the braces (implicit return) or writereturn res.json();explicitly. - No
.catchanywhere. A rejected promise with no handler becomes an "unhandled rejection" — Node.js logs a warning and may crash. Always attach a.catchat the end of every top-level chain. - Calling
.thenon a non-promise..thenonly exists on promise objects. If you accidentally call it on a normal value ((42).then(...)), JavaScript throwsTypeError: .then is not a function. Promises live in your async APIs and your ownasyncfunctions — anything else won't have.then.
🎯 Practice task
Build a promise-chained data pipeline. 20-25 minutes.
-
In your
js-for-qafolder, createusers.json(5 users withnameandrole, mix of admin/member) if it doesn't exist already. -
Create
chain.js:const fs = require("node:fs/promises"); fs.readFile("users.json", "utf-8") .then(text => JSON.parse(text)) .then(users => users.filter(u => u.role === "admin")) .then(admins => fs.writeFile("admins.json", JSON.stringify(admins, null, 2)).then(() => admins) ) .then(admins => console.log(`Wrote ${admins.length} admins`)) .catch(err => console.error("Failed:", err.message)) .finally(() => console.log("Done")); -
Run with
node chain.js. Confirmadmins.jsonis created and the log lines print in order. -
Rename
users.jsontousers-temp.jsonso the read fails. Rerun. The chain should print "Failed:" with an error message, then "Done." Rename it back. -
Stretch: create a
config.jsoncontaining{ "minAdmins": 1 }. UsePromise.all([fs.readFile("users.json", "utf-8"), fs.readFile("config.json", "utf-8")])to read both in parallel. Destructure the resolved array, parse both, and assert that the admin count is at leastconfig.minAdmins. Print pass/fail.
The next lesson takes the same chain and rewrites it with async/await — same logic, but readable as ordinary top-to-bottom code.