Promises
A Promise is an object that represents the eventual result of an asynchronous operation. Instead of passing a callback into a function and waiting for it to fire, you receive a promise immediately and attach handlers to it. Promises give async code a uniform shape, make errors composable, and form the foundation that async/await is built on top of. Understanding them is essential to writing reliable, readable asynchronous JavaScript.
The promise lifecycle
A promise is always in one of three states:
| State | Meaning | Transitions to |
|---|---|---|
pending | The operation has not completed yet. | fulfilled or rejected |
fulfilled | The operation succeeded and produced a value. | (final) |
rejected | The operation failed and produced a reason (usually an Error). | (final) |
Once a promise leaves pending it is settled, and its state and value can never change again. This immutability is what makes promises safe to pass around and attach multiple handlers to. A settled promise that you then() later will still deliver its result.
┌──────────────┐ resolve(value) ┌──────────────┐
│ │ ────────────────▶ │ fulfilled │
new ────▶ │ pending │ └──────────────┘
│ │ reject(reason) ┌──────────────┐
└──────────────┘ ────────────────▶ │ rejected │
└──────────────┘
Creating a promise
You construct a promise with the Promise constructor, passing an executor function. The executor receives two functions—resolve and reject—that you call to settle the promise. The executor runs synchronously and immediately.
function wait(ms) {
return new Promise((resolve, reject) => {
if (ms < 0) {
reject(new Error("Delay must be non-negative"));
return;
}
setTimeout(() => resolve(`waited ${ms}ms`), ms);
});
}
wait(200).then((msg) => console.log(msg));
Output:
waited 200ms
For values you already have, the static helpers are shorter than the constructor. Promise.resolve(value) returns an already-fulfilled promise, and Promise.reject(reason) returns an already-rejected one.
Promise.resolve(42).then((n) => console.log(n)); // 42
Only the first call to
resolveorrejectmatters—subsequent calls are ignored. A promise cannot be “un-settled,” so guard your executor logic accordingly.
Consuming with then, catch, and finally
You read a promise’s outcome with three methods. Each returns a new promise, which is what makes chaining possible.
then(onFulfilled, onRejected)— runsonFulfilledwith the value when fulfilled. The optional second argument handles rejection.catch(onRejected)— handles a rejection anywhere earlier in the chain. Equivalent tothen(undefined, onRejected).finally(onSettled)— runs once the promise settles, regardless of outcome. It receives no argument and is ideal for cleanup.
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 1) resolve({ id, name: "Ada" });
else reject(new Error(`No user with id ${id}`));
}, 100);
});
}
fetchUser(1)
.then((user) => console.log("Got:", user.name))
.catch((err) => console.error("Failed:", err.message))
.finally(() => console.log("Request complete"));
fetchUser(99)
.then((user) => console.log("Got:", user.name))
.catch((err) => console.error("Failed:", err.message))
.finally(() => console.log("Request complete"));
Output:
Got: Ada
Request complete
Failed: No user with id 99
Request complete
Because handlers are queued as microtasks, they always run after the current synchronous code finishes—even for an already-settled promise. This guarantees consistent, predictable ordering.
console.log("start");
Promise.resolve("now").then((v) => console.log(v));
console.log("end");
Output:
start
end
now
Converting callbacks to promises
Older Node-style APIs use error-first callbacks: callback(err, result). You can wrap any such API in a promise so it fits modern control flow. This pattern is called promisification.
import { readFile } from "node:fs";
function readFileAsync(path, encoding = "utf8") {
return new Promise((resolve, reject) => {
readFile(path, encoding, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
readFileAsync("./config.json")
.then((text) => console.log("Read", text.length, "chars"))
.catch((err) => console.error("Read failed:", err.message));
Node ships util.promisify to do this automatically for any error-first function, and most core modules now expose promise versions directly (e.g. node:fs/promises).
import { promisify } from "node:util";
import { readFile } from "node:fs";
const readFileP = promisify(readFile);
readFileP("./config.json", "utf8").then((text) => console.log(text.length));
A thrown exception inside
then/catch/executor automatically rejects the returned promise. Never wrap promise-returning code intry/catchexpecting to catch async failures—use.catch()instead.
Best Practices
- Always attach a
.catch()(or terminal rejection handler) to every chain—unhandled rejections crash Node processes and warn in browsers. - Return values from inside
thencallbacks so the next handler receives them; forgetting toreturnis the most common chaining bug. - Reject with
Errorobjects, not strings, so you keep stack traces. - Prefer the static helpers
Promise.resolve/rejectand existing promise APIs over hand-rolling the constructor. - Use
finallyfor cleanup (closing connections, hiding spinners) rather than duplicating it in boththenandcatch. - Reach for
async/awaitfor sequential logic, but keep promises for combinators likePromise.all.