Promises in Node.js
A promise is an object that represents the eventual result of an asynchronous operation. Instead of passing a callback that fires later, you receive a value you can attach handlers to, compose, and pass around like any other reference. Promises form the foundation of modern asynchronous JavaScript and are what async/await is built on, so understanding them is essential even when you rarely write .then() by hand.
Promise states
Every promise lives in exactly one of three states. It starts pending, then transitions once — and only once — to either fulfilled (resolved with a value) or rejected (failed with a reason). Once it settles, the state and value are immutable; later calls to resolve or reject are ignored.
| State | Meaning | Triggered by |
|---|---|---|
pending | Operation in progress, not yet settled | Initial state |
fulfilled | Completed successfully with a value | resolve(value) |
rejected | Failed with a reason | reject(error) or a thrown error |
A promise that has reached fulfilled or rejected is collectively described as settled. The transition is one-way and final.
Creating a promise
The new Promise constructor takes an executor function that runs synchronously and immediately. It receives two functions — conventionally resolve and reject — that you call to settle the promise.
function delay(ms, value) {
return new Promise((resolve, reject) => {
if (ms < 0) {
reject(new Error("delay must be non-negative"));
return;
}
setTimeout(() => resolve(value), ms);
});
}
const result = await delay(500, "done");
console.log(result);
Output:
done
If the executor throws, the promise is automatically rejected with the thrown error — you do not need a try/catch inside it.
Tip: Only ever construct a promise with
new Promisewhen wrapping something that is not already promise-based (like a timer or an event emitter). If a function already returns a promise, wrapping it again is an anti-pattern known as the “promise constructor anti-pattern.”
then, catch, and finally
You consume a settled promise with three methods. .then() registers a handler for fulfillment (and optionally rejection), .catch() handles rejection, and .finally() runs cleanup regardless of outcome.
delay(200, 42)
.then((value) => console.log("fulfilled with", value))
.catch((err) => console.error("rejected:", err.message))
.finally(() => console.log("settled"));
Output:
fulfilled with 42
settled
.then(onFulfilled, onRejected) accepts a second argument, but .catch(fn) is just sugar for .then(undefined, fn) and reads more clearly. The .finally() callback receives no arguments and does not change the resolved value — it passes the original outcome through.
Chaining
Because .then() returns a new promise, calls compose into a chain. Whatever you return from a handler becomes the fulfillment value of the next link. If you return a promise, the chain waits for it to settle before continuing — this is how you flatten nested asynchronous steps into a flat, readable sequence.
function fetchUserId() {
return Promise.resolve(7);
}
fetchUserId()
.then((id) => {
console.log("id:", id);
return delay(100, id * 2); // returning a promise; chain waits
})
.then((doubled) => {
console.log("doubled:", doubled);
return doubled + 1; // plain value flows to the next handler
})
.then((final) => console.log("final:", final));
Output:
id: 7
doubled: 14
final: 15
Error propagation
A rejection skips every .then() fulfillment handler until it reaches a .catch() (or a .then with a rejection handler). This means a single .catch() at the end of a chain handles errors from any step before it — much like a try/catch around a block of synchronous code. Throwing inside a handler also rejects the resulting promise.
Promise.resolve()
.then(() => {
throw new Error("step one failed");
})
.then(() => console.log("this is skipped"))
.catch((err) => console.error("caught:", err.message))
.then(() => console.log("chain recovered"));
Output:
caught: step one failed
chain recovered
Notice the chain continues after the .catch() — handling an error returns the chain to a fulfilled state, so subsequent .then() handlers run normally.
Warning: An unhandled rejection — a rejected promise with no
.catch()— emits anunhandledRejectionevent. In Node.js 15+ the default behavior terminates the process with a non-zero exit code. Always attach a rejection handler orawaitinside atry/catch.
Converting callback APIs
Much of Node’s older surface uses error-first callbacks: callback(err, result). The built-in node:util module provides promisify to convert any function following that convention into one that returns a promise.
import { promisify } from "node:util";
import { readFile } from "node:fs";
const readFileAsync = promisify(readFile);
const text = await readFileAsync("package.json", "utf8");
console.log(text.length, "characters read");
Most core modules already ship promise-based variants — prefer import { readFile } from "node:fs/promises" over promisifying node:fs manually. For your own custom callback functions, promisify remains the cleanest bridge.
function legacyLookup(key, callback) {
setTimeout(() => {
if (!key) callback(new Error("key required"));
else callback(null, `value-for-${key}`);
}, 50);
}
const lookupAsync = promisify(legacyLookup);
console.log(await lookupAsync("color"));
Output:
value-for-color
In CommonJS the imports are equivalent: const { promisify } = require("node:util").
Best Practices
- Always terminate a promise chain with
.catch(), orawaitit inside atry/catch, so no rejection goes unhandled. - Return promises from inside
.then()handlers rather than nesting new chains — keep the chain flat. - Avoid the
new Promiseconstructor when a promise-returning API already exists; wrapping it adds bugs and obscures errors. - Prefer the
node:fs/promisesstyle built-in promise APIs over manually promisifying their callback counterparts. - Remember that
.finally()is for cleanup only — it cannot alter the resolved value or swallow a rejection. - Quote every settled value as immutable: once a promise resolves or rejects, that result never changes, so design around a single outcome.