Promises in Node.js
A Promise is an object that represents the eventual result of an asynchronous operation: a value if it succeeds, or a reason if it fails. Node.js is asynchronous almost everywhere I/O is involved — reading files, querying a database, making an HTTP request — and promises give you a standard, composable way to write and reason about that work instead of nesting callbacks inside callbacks. Since Node 8, core modules have grown promise-based counterparts such as fs/promises, and async/await (built directly on promises) is now the normal way to consume them.
This lesson covers what a promise actually is, how it interacts with Node’s event loop, how to create and combine promises, and the mistakes that trip up almost everyone the first time.
Overview: How Promises Work
A promise is always in one of three states:
- pending — the initial state; the operation hasn’t finished yet.
- fulfilled — the operation completed successfully and the promise now holds a value.
- rejected — the operation failed and the promise now holds a reason (usually an
Error).
A promise moves from pending to fulfilled or rejected exactly once — this is called settling. Once settled, a promise’s outcome is permanent; calling resolve or reject again has no effect. This immutability is what makes promises reliable to pass around: no caller can accidentally re-trigger or overwrite a result.
You create a promise with the Promise constructor, which takes an executor function (resolve, reject) => { ... }. Node calls the executor synchronously and immediately, before new Promise(...) even returns. Inside it you kick off the async work (a timer, a file read, a network call) and call resolve(value) or reject(error) when it’s done. Most of the time you won’t write raw executors yourself — you’ll consume promises already returned by fs/promises, fetch, database drivers, and so on — but understanding the constructor makes everything else click.
The methods .then(), .catch(), and .finally() attach reactions to a promise. Critically, those reactions never run synchronously, even if the promise is already settled by the time you attach them. They are scheduled on the JavaScript microtask queue, which Node (via the V8 engine and Node’s own process.nextTick queue) drains after the currently running synchronous code finishes, but before the event loop moves on to timers, I/O callbacks, or setImmediate. That ordering guarantee is what makes async/await behave predictably, and it’s covered in detail in the “Under the Hood” section below.
Every call to .then() or .catch() returns a new promise, which is what makes chaining possible: each step in the chain waits for the previous one and can transform the value or recover from an error before passing it along.
Syntax
const promise = new Promise((resolve, reject) => {
// async work here
if (/* success */ true) {
resolve(value);
} else {
reject(new Error("something went wrong"));
}
});
promise
.then((value) => { /* handle success */ })
.catch((err) => { /* handle failure */ })
.finally(() => { /* always runs, success or failure */ });
| Member | Description |
|---|---|
new Promise(executor) |
Creates a promise; the executor receives resolve and reject functions. |
.then(onFulfilled, onRejected) |
Registers callbacks for success and/or failure; returns a new promise. |
.catch(onRejected) |
Shorthand for .then(undefined, onRejected). |
.finally(onFinally) |
Runs regardless of outcome; does not receive the value or error. |
Promise.resolve(value) |
Returns a promise already fulfilled with value (or the value itself if it’s already a promise). |
Promise.reject(reason) |
Returns a promise already rejected with reason. |
Promise.all(iterable) |
Waits for every promise to fulfill; rejects immediately if any one rejects. |
Promise.allSettled(iterable) |
Waits for every promise to settle; never rejects, gives you each outcome. |
Promise.race(iterable) |
Settles as soon as the first promise settles, whether it fulfills or rejects. |
Promise.any(iterable) |
Fulfills as soon as any one promise fulfills; rejects only if all reject. |
Examples
Example 1: A basic promise with async/await
function delay(ms, value) {
return new Promise((resolve) => {
setTimeout(() => resolve(value), ms);
});
}
async function main() {
console.log("start");
const result = await delay(500, "done after 500ms");
console.log(result);
console.log("end");
}
main();
start
done after 500ms
end
delay() wraps setTimeout in a promise so it can be awaited. Note that "start" logs immediately, then Node moves on to other work while the 500ms timer runs in the background — nothing is blocked. When the timer fires, resolve(value) settles the promise and await resumes main() where it left off.
Example 2: Chaining with real error handling
const fs = require("node:fs/promises");
const path = require("node:path");
async function loadConfig(configPath) {
try {
const absolutePath = path.resolve(configPath);
const raw = await fs.readFile(absolutePath, "utf8");
return JSON.parse(raw);
} catch (err) {
if (err.code === "ENOENT") {
throw new Error(`Config file not found: ${configPath}`);
}
throw err;
}
}
async function main() {
const config = await loadConfig("./config.json");
console.log("Loaded config:", config);
}
main().catch((err) => {
console.error("Failed to start:", err.message);
process.exitCode = 1;
});
Failed to start: Config file not found: ./config.json
This uses the promise-based fs/promises API rather than fs.readFile‘s callback form or the blocking fs.readFileSync. The try/catch inside loadConfig turns a generic ENOENT error into a clearer message, then re-throws so the caller still sees a rejected promise. The top-level .catch() on main() is the safety net that stops an unhandled rejection from crashing the process (shown here for a missing config.json; with a valid file present it would log the parsed object instead).
Example 3: Running work concurrently with Promise.allSettled
const fs = require("node:fs/promises");
async function readAll(paths) {
const results = await Promise.allSettled(
paths.map((p) => fs.readFile(p, "utf8"))
);
results.forEach((result, i) => {
if (result.status === "fulfilled") {
console.log(`${paths[i]}: ${result.value.length} bytes`);
} else {
console.log(`${paths[i]}: failed (${result.reason.code})`);
}
});
}
readAll(["./package.json", "./missing.txt"]);
./package.json: 512 bytes
./missing.txt: failed (ENOENT)
paths.map(...) starts all the reads at once — they run concurrently rather than one after another, which matters a lot for I/O-bound work. Promise.allSettled waits for every one of them to finish, successful or not, and gives back an array of { status, value } or { status, reason } objects. Compare this with Promise.all, which would have rejected the whole batch the instant missing.txt failed, discarding the successful package.json read.
Under the Hood: Execution Order
Node.js runs one call stack at a time. When that stack empties, Node doesn’t jump straight to the next timer — it first drains two microtask-style queues, in this order:
process.nextTickqueue — Node-specific, highest priority.- Promise microtask queue — standard JavaScript, includes every
.then()/.catch()/.finally()reaction and everything after anawait.
Only after both queues are completely empty does the event loop proceed to its phases: timers (setTimeout/setInterval), pending I/O callbacks, poll, check (setImmediate), and close callbacks. This example makes the ordering concrete:
console.log("1: sync");
setTimeout(() => console.log("2: setTimeout (timer phase)"), 0);
Promise.resolve().then(() => console.log("3: promise microtask"));
process.nextTick(() => console.log("4: nextTick"));
console.log("5: sync");
1: sync
5: sync
4: nextTick
3: promise microtask
2: setTimeout (timer phase)
The two synchronous console.log calls run first, in source order, before anything else gets a turn. Once the stack is empty, the nextTick queue drains completely before the promise microtask queue even starts, and both queues drain fully before Node touches the timer phase — which is why setTimeout(..., 0) still prints last. When you await a promise, everything after that line is effectively scheduled the same way a .then() callback would be: it does not run synchronously, and other pending microtasks can run first.
Common Mistakes
Mistake 1: Forgetting to return inside a .then() chain
Wrong:
function processOrder(orderId) {
return fetchOrder(orderId).then((order) => {
saveOrderLog(order); // missing "return" -- the chain doesn't wait for this
}).then(() => {
console.log("Order processed");
});
}
Because saveOrderLog(order)‘s result isn’t returned, the next .then() fires as soon as the first promise settles, not after the log write finishes. If saveOrderLog is slow or fails, “Order processed” can print before the log is actually written, and any rejection from saveOrderLog is silently lost instead of propagating down the chain.
Correct:
function processOrder(orderId) {
return fetchOrder(orderId).then((order) => {
return saveOrderLog(order); // now the chain waits for the log write
}).then(() => {
console.log("Order processed");
});
}
Mistake 2: Unhandled promise rejections
Wrong:
async function loadUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
loadUser(42).then((user) => console.log(user));
// If the request fails, or the server returns a non-2xx status,
// this rejection has no .catch() -- Node will print a warning and,
// by default, terminate the process.
Since Node 15, an unhandled promise rejection crashes the process by default (previously it only logged a warning). Relying on that as your error handling strategy means one flaky network call takes down your whole server.
Correct:
async function loadUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
return res.json();
}
loadUser(42)
.then((user) => console.log(user))
.catch((err) => console.error("Could not load user:", err.message));
Every promise chain and every await needs a landing spot for failure — either a .catch() on the chain or a surrounding try/catch. fetch only rejects on network-level failures, not on HTTP error statuses, so checking res.ok and throwing explicitly is required too.
Best Practices
- Always end a promise chain with
.catch(), or wrapawaitcalls intry/catch— never leave a promise with no rejection handler. - Use
Promise.all()(orallSettled()when partial failure is acceptable) to run independent async operations concurrently instead of awaiting them one at a time in a loop. - Avoid the “Promise constructor antipattern”: don’t wrap a function that already returns a promise in
new Promise((resolve, reject) => ...); just return or await it directly. - Prefer the built-in promise APIs (
fs/promises,dns/promises, globalfetch) over manually promisifying things yourself; useutil.promisifyonly for older callback-based APIs that don’t already have one. - Treat the
process.on("unhandledRejection", ...)event as a last-resort safety net for logging, not as your primary error-handling strategy. - Remember that
.then()/.catch()callbacks and code afterawaitrun as microtasks — they never block the current synchronous code, but they do run before any timer or I/O callback. - Keep
asyncfunctions small and give them names that describe the operation, so stack traces from rejected promises stay readable.
Practice Exercises
- Write a function
retry(fn, attempts)that calls an async functionfn, and if it rejects, tries again up toattemptstimes before finally rejecting. Test it against a function that fails the first two times and succeeds on the third. - Using
fs/promises, write a script that reads three file paths from an array, fetches all three concurrently withPromise.allSettled, and prints a summary showing how many succeeded and how many failed, along with the failure reasons. - Predict the console output of a script that mixes
console.log,process.nextTick,Promise.resolve().then(), andsetTimeout(fn, 0)calls in a different order than the “Under the Hood” example above, then run it in Node to check your answer.
Summary
- A promise is pending, then settles exactly once to either fulfilled (with a value) or rejected (with a reason).
- The executor passed to
new Promise()runs synchronously;.then()/.catch()/.finally()callbacks always run later, as microtasks. - Node drains
process.nextTickcallbacks, then promise microtasks, completely before moving on to timers orsetImmediate. Promise.all()fails fast on the first rejection;Promise.allSettled()waits for everything and reports every outcome;Promise.race()andPromise.any()settle on the first result of their kind.- Every promise chain needs a rejection handler — an unhandled rejection crashes a modern Node process by default.
- Prefer the built-in promise APIs (
fs/promises, globalfetch) andasync/awaitover manual promise construction or callback nesting.
