Promises
Create promises with new Promise, chain them with then, catch and finally, and use Promise.withResolvers and Promise.try.
What you will learn
- Describe the three states of a promise and what it means for a promise to settle
- Create a promise with new Promise and settle it with resolve and reject
- Chain then, catch and finally, and return values or promises from handlers
- Write a promise-returning sleep helper
- Use Promise.withResolvers and Promise.try
On this page
A promise is an object that stands for a result that is not ready yet. Where a callback API says “call me back”, a promise API says “here is a receipt, ask it later”. The receipt is a normal value. You can store it, pass it around, return it from a function and attach several readers to it. That one change removes most of the problems in the callbacks lesson: the result can be read more than once, it settles only once, and errors travel along the chain.
The three states
A promise is always in one of three states:
- pending: the result is not known yet.
- fulfilled: the work succeeded, and the promise holds a value.
- rejected: the work failed, and the promise holds a reason, normally an
Error.
A promise that is fulfilled or rejected is settled. It never changes again. The word “resolved” is looser. Strictly, a promise is resolved when it is settled or locked in to follow another promise; in everyday speech people use “resolved” for “fulfilled”. This course says fulfilled and rejected when the difference matters.
console.log shows the state:
const pending = new Promise((resolve) => { setTimeout(resolve, 10, "ready");});console.log(pending);await pending;console.log(pending);
console.log(Promise.resolve(42));const rejected = Promise.reject(new Error("nope"));rejected.catch((error) => console.log("caught", error.message));Promise { <pending> }Promise { 'ready' }Promise { 42 }caught nopeThe first line was logged while the timer was still running. After await pending, the same object is fulfilled with 'ready'. Promise.resolve(value) makes a promise that is already fulfilled. Promise.reject(reason) makes one that is already rejected. The rejected promise gets a .catch handler on the very next line. The reason for that will become clear in the lesson on async errors: a rejected promise nobody handles crashes a Node program.
Creating a promise
new Promise(executor) takes a function, the executor, and calls it at once with two functions: resolve and reject. You start your asynchronous work inside the executor, and call one of the two functions when it ends.
console.log("before");const p = new Promise((resolve) => { console.log("inside executor"); resolve("value"); console.log("after resolve call");});p.then((value) => console.log("then got", value));console.log("after then call");beforeinside executorafter resolve callafter then callthen got valueThe executor ran synchronously, in the middle of the new Promise line, and resolve("value") did not stop it: the line after it still ran. But the then callback ran last. Callbacks passed to then are never called synchronously, even for a promise that is already fulfilled. They are queued as microtasks, as the event loop lesson showed.
Only the first call counts. Once resolve or reject has been called, later calls do nothing.
Reading a promise: then, catch and finally
promise.then(onFulfilled)runs the function with the value.promise.catch(onRejected)runs the function with the reason.promise.finally(onSettled)runs the function in either case, with no arguments, for clean-up.
All three return a new promise. That is what makes chaining possible.
Chaining
The value a handler returns becomes the value of the promise that then returned. If the handler returns a promise, the chain waits for it and continues with its value.
Promise.resolve(2) .then((n) => n * 2) .then((n) => { console.log("got", n); return new Promise((resolve) => setTimeout(resolve, 10, n + 1)); }) .then((n) => console.log("after the wait:", n));got 4after the wait: 5The first then returns a number, and the second receives it. The second then returns a promise that is fulfilled after 10 milliseconds, and the third then receives 5 only after that. The whole chain is flat: each step is one line of indentation, however many steps there are.
Always return
If a handler starts more asynchronous work and does not return it, the chain does not wait for it. Such a promise is called “floating”: nobody is waiting for it, and nobody will see its error.
const step = (n) => Promise.resolve(n + 1);
const lost = await step(1).then((n) => { step(n);});console.log("without return:", lost);
const kept = await step(1).then((n) => { return step(n);});console.log("with return:", kept);without return: undefinedwith return: 3In the first call, the then handler called step(n) but did not return the result. The next step in the chain saw undefined. In the second, return step(n) made the chain wait and pass on the value.
Errors travel down the chain
When a handler throws, or returns a rejected promise, the promise from that then is rejected. Every then after it is skipped, until a catch handles the error. A catch that returns a value recovers: the chain continues as fulfilled.
Promise.resolve("start") .then((value) => { console.log("step 1 got", value); throw new Error("step 1 failed"); }) .then(() => console.log("step 2 (skipped)")) .catch((error) => { console.log("caught:", error.message); return "recovered"; }) .then((value) => console.log("step 3 got", value)) .finally(() => console.log("finally runs last"));step 1 got startcaught: step 1 failedstep 3 got recoveredfinally runs lastStep 2 never ran. The catch turned the failure into the value "recovered", step 3 received it, and finally ran last. This is the same flow as try, catch and finally in synchronous code, as the beginner lesson on errors described.
finally is for clean-up. It does not receive the result, and by default it does not change it:
const kept = await Promise.resolve("value").finally(() => "ignored");console.log(kept);
const failing = Promise.reject(new Error("boom")).finally(() => { console.log("cleanup runs");});try { await failing;} catch (error) { console.log("still rejected:", error.message);}valuecleanup runsstill rejected: boomThe fulfilled value "value" passed through, in spite of the "ignored" returned by the handler. The rejection passed through too, after the clean-up message. If a finally handler throws, or returns a rejected promise, that new rejection replaces the result.
A sleep helper
Wrapping setTimeout in a promise is the first helper most programs grow. It uses the third argument of setTimeout, which is passed to the callback, so resolve receives the value.
function sleep(ms, value) { return new Promise((resolve) => setTimeout(resolve, ms, value));}
console.log("waiting");sleep(20, "first") .then((value) => { console.log(value); return sleep(10, "second"); }) .then((value) => console.log(value));waitingfirstsecondThe rest of this module uses sleep for fake slow work. Node also ships it ready-made as setTimeout from node:timers/promises, which the lesson on cancellation uses.
Promise.withResolvers
Sometimes the code that settles the promise lives somewhere other than the executor: in an event handler, or a different function. Declaring let resolve outside, and filling it in from the executor, is awkward. Promise.withResolvers() returns the promise together with its resolve and reject functions as an object. It was added in ES2024 and runs on Node 22.
const { promise, resolve } = Promise.withResolvers();
promise.then((value) => console.log("A got", value));promise.then((value) => console.log("B got", value));
setTimeout(() => resolve("data"), 10);setTimeout(() => resolve("ignored"), 20);A got dataB got dataTwo readers wait on the same promise, and both got the value when resolve was called at 10 ms. The second call, at 20 ms, did nothing, because a promise settles only once.
Promise.try
Requires Node.js 24 or later Promise.try(fn, ...args) calls fn with the arguments and always gives you back a promise. A returned value becomes a fulfilled promise. A thrown error becomes a rejected one. A returned promise is passed on. You want it for functions that might throw before they get to the asynchronous part, so that you handle both kinds of failure in one place.
function parse(text) { if (text === "") throw new Error("empty input"); return JSON.parse(text);}
console.log(await Promise.try(parse, '{"a":1}'));try { await Promise.try(parse, "");} catch (error) { console.log("rejected:", error.message);}
try { Promise.resolve(parse(""));} catch (error) { console.log("threw at once:", error.message);}{ a: 1 }rejected: empty inputthrew at once: empty inputThe last block shows what Promise.try saves you from: Promise.resolve(parse("")) runs parse first, so its throw happens in your own code, before any promise exists.
Thenables, and the cost of resolving with a promise
A thenable is any object with a then method. Promise.resolve, resolve(...) inside the constructor, and await all treat a thenable as a promise and wait for it, which is how libraries with their own promise types work with native ones. Following a thenable or a promise takes a little longer than following a plain value. This program shows it by counting microtask turns:
const thenable = { then(resolve) { resolve("from a thenable"); },};Promise.resolve(thenable).then((value) => console.log(value));
new Promise((resolve) => resolve(Promise.resolve("inner"))).then(() => console.log("resolved with a promise"),);Promise.resolve() .then(() => console.log("tick 1")) .then(() => console.log("tick 2")) .then(() => console.log("tick 3")) .then(() => console.log("tick 4"));tick 1from a thenabletick 2resolved with a promisetick 3tick 4A plain fulfilled promise would run its callback on tick 1. Following a thenable costs one extra turn, because the engine calls its then in a microtask of its own, and a promise that is resolved with another promise costs two. You rarely need to count ticks, but now you know why code that returns a promise from a then handler runs a little later than code that returns a value.
One more detail of then: it takes a second argument, a handler for rejection. promise.then(onFulfilled, onRejected) is not the same as promise.then(onFulfilled).catch(onRejected). With two arguments, onRejected handles only the promise itself, not an error thrown by onFulfilled. The .catch at the end handles both, which is why the chain form is the usual choice.
Common pitfalls
- Forgetting to
returninsidethen. The next step getsundefinedand does not wait. - Nesting
theninsidethen. Return the inner promise and keep the chain flat. - Assuming
thencallbacks run immediately. They always run later. - Leaving a rejection unhandled. End chains with
catch, orawaitthem insidetry. - Expecting
finallyto see the value. It gets no arguments. - Calling
resolveand carrying on. The code after it in the executor still runs.
Exercises
Write total(json), which returns a promise. Starting from the text of a JSON array of numbers, it must parse the text, add up the numbers and give back the string total = <sum>. If anything fails, the promise must fulfil with bad input (<error name>) instead. Use then and catch, not await inside total. Call it with "[1, 2, 3]" and with "[1,".
Expected output:
total = 6bad input (SyntaxError)Show solution
Each then does one step, and one catch at the end handles a failure from any of them. The catch returns a string, so the final promise is fulfilled either way.
function total(json) { return Promise.resolve(json) .then((text) => JSON.parse(text)) .then((numbers) => numbers.reduce((sum, n) => sum + n, 0)) .then((sum) => `total = ${sum}`) .catch((error) => `bad input (${error.name})`);}
console.log(await total("[1, 2, 3]"));console.log(await total("[1,"));total = 6bad input (SyntaxError)doubleLater is a callback API: it calls back with n * 2 after 5 ms, or with a TypeError if n is not a number. Without util.promisify, write doubleAsync(n) that returns a promise for the result. Print doubleAsync(21), and then the name and message of the error from doubleAsync("x").
Expected output:
42TypeError: not a numberShow solution
The executor starts the callback-style call and settles the promise from inside the callback: reject for an error, resolve for a value. This is what promisify does for you.
function doubleLater(n, callback) { setTimeout(() => { if (typeof n !== "number") { return callback(new TypeError("not a number")); } callback(null, n * 2); }, 5);}
function doubleAsync(n) { return new Promise((resolve, reject) => { doubleLater(n, (error, result) => { if (error) reject(error); else resolve(result); }); });}
console.log(await doubleAsync(21));await doubleAsync("x").catch((e) => console.log(e.name + ":", e.message));42TypeError: not a numberWhat does this program print, and in which order? Work it out on paper first.
console.log("1");const p = new Promise((resolve) => { console.log("2"); resolve("3");});p.then((value) => { console.log(value); throw new Error("4");}) .catch(() => console.log("5")) .finally(() => console.log("6"));console.log("7");Show solution
127356The executor runs at once and prints 2, and the last line prints 7 before any handler. Then the handlers run, one after another: the first prints 3 and throws. The throw rejects the promise from then, so the catch handler prints 5. The handler returned normally, so the promise it returned is fulfilled, and finally prints 6.
Summary
- A promise is pending, then fulfilled or rejected. It settles once and then never changes.
new Promise(executor)runs the executor at once. Handlers added withthen,catchandfinallyalways run later, as microtasks.- Each handler returns a new promise, so steps chain. Return a value to pass it on, or a promise to wait for it.
- A throw or a rejection skips the
thensteps until acatch. Acatchthat returns a value recovers. Promise.withResolvers()gives you a promise and its settling functions.Promise.try(fn)turns a function that may throw into a promise.
Next: async and await, which let you write this flow like ordinary sequential code.