A promise is an object that stands for a result you do not have yet. Instead of passing a callback into an asynchronous function, you get a promise back and attach what should happen next. Promises fix the nesting, error handling and trust problems from the previous lesson, and they are the foundation of async/await and fetch().
The three states
A promise is always in one of three states:
| State | Meaning |
|---|---|
| pending | The work has not finished yet |
| fulfilled | The work succeeded, and the promise holds a value |
| rejected | The work failed, and the promise holds a reason, usually an Error |
A promise that is fulfilled or rejected is settled. Settling happens once. After that the state and value never change, which is why a promise-based function cannot "call you back twice".
Consuming a promise
Most of the time you use promises that other code creates. fetch() returns one, and so does readFile from node:fs/promises. You react to the result with three methods:
fetch("data/courses.json")
.then((response) => {
console.log("Status:", response.status);
})
.catch((error) => {
console.error("Request failed:", error.message);
})
.finally(() => {
console.log("Finished, whatever happened");
});
.then(onFulfilled)runs when the promise fulfils, with the value..catch(onRejected)runs when the promise rejects, with the reason..finally(onFinally)runs either way, with no argument. It is the place to hide a loading spinner.
fetch() gets a whole lesson of its own in lesson 33. For now, a timer makes a better playground.
Creating a promise
You create a promise with the Promise constructor. It takes an executor function, which receives two functions: resolve to fulfil the promise and reject to reject it.
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
delay(1000).then(() => console.log("One second later"));
delay wraps a callback-based API, setTimeout, in a promise. This is the main reason to call new Promise yourself: to turn an old callback API into a promise. Here is the course loader from lesson 30, rewritten:
const courses = [
{ id: 1, title: "JavaScript Basics", authorId: 7 },
{ id: 2, title: "CSS Grid in Practice", authorId: 9 },
];
function loadCourse(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const course = courses.find((c) => c.id === id);
if (course) {
resolve(course);
} else {
reject(new Error(`No course with id ${id}`));
}
}, 500);
});
}
loadCourse(1)
.then((course) => console.log(course.title)) // → JavaScript Basics
.catch((error) => console.error(error.message));
loadCourse(99)
.then((course) => console.log(course.title))
.catch((error) => console.error(error.message)); // → No course with id 99
Always reject with an Error object, not a string. An Error carries a stack trace that tells you where the failure happened.
If the executor throws, the promise rejects with the thrown error, so you do not need a try...catch inside it.
Node.js already ships promise versions of most of its APIs: node:fs/promises, node:timers/promises and others. Check for one before wrapping a callback API yourself.
Chaining
Every call to .then() returns a new promise. That new promise settles with whatever your handler returns:
- Return a plain value, and the next
.then()receives it. - Return a promise, and the chain waits for it, then passes on its result.
- Throw an error, and the chain skips to the next
.catch().
That is what flattens the pyramid from lesson 30:
loadCourse(1)
.then((course) => loadAuthor(course.authorId))
.then((author) => loadCoursesBy(author.id))
.then((otherCourses) => render(otherCourses))
.catch((error) => showError(error));
Each step waits for the previous one, the code reads top to bottom, and one .catch() handles a failure at any step.
Here is a chain you can run in the console that transforms values step by step:
Promise.resolve(2)
.then((n) => n * 10)
.then((n) => delay(500).then(() => n + 1))
.then((n) => console.log(n)); // → 21 (after half a second)
Promise.resolve(value) creates a promise that is already fulfilled with value. Promise.reject(error) creates one that is already rejected.
The most common mistake: forgetting to return
loadCourse(1)
.then((course) => {
loadAuthor(course.authorId); // no return!
})
.then((author) => {
console.log(author); // → undefined
});
Without return, the handler returns undefined, so the next step runs immediately with undefined and does not wait for the author. Worse, if loadAuthor rejects, nothing catches it. With arrow functions, an expression body without braces returns automatically, which is one reason chains are usually written that way.
Do not nest
// Avoid: nesting brings the pyramid back
loadCourse(1).then((course) => {
loadAuthor(course.authorId).then((author) => {
render(course, author);
});
});
If a later step needs an earlier value, async/await (next lesson) is the clean answer. In a pure promise chain, you can return both values together, for example as an object.
Error handling in detail
A rejection travels down the chain, skipping every .then() until it reaches a .catch():
Promise.resolve(1)
.then(() => {
throw new Error("Step 2 failed");
})
.then(() => console.log("Skipped"))
.catch((error) => {
console.error(error.message); // → Step 2 failed
return "recovered";
})
.then((value) => console.log(value)); // → recovered
.catch() also returns a new promise. If the handler returns normally, the chain carries on as fulfilled. If you cannot recover, throw again (or throw a new, more specific error) so that callers further down still see a failure.
.then() also accepts a second argument for rejections, .then(onFulfilled, onRejected). It is rarely used, because onRejected there does not catch errors thrown by onFulfilled in the same call. A separate .catch() at the end catches both.
finally
.finally() runs whatever the outcome and passes the original result through unchanged:
showSpinner();
loadCourse(1)
.then((course) => render(course))
.catch((error) => showError(error))
.finally(() => hideSpinner());
Unhandled rejections
A rejected promise with no .catch() anywhere is an unhandled rejection. Browsers log it in the console as "Uncaught (in promise)". In Node.js, the default since version 15 is to crash the process. Every chain should end in a .catch(), or be returned to a caller that handles it.
When do handlers run?
Promise handlers never run immediately, even if the promise is already settled. They are queued as microtasks, which run as soon as the current synchronous code has finished and before any timer callback.
console.log("A");
setTimeout(() => console.log("D: timeout"), 0);
Promise.resolve().then(() => console.log("C: promise"));
console.log("B");
// → A
// → B
// → C: promise
// → D: timeout
The synchronous lines run first (A, B). Then the microtask queue empties (C). Only then does the timer's callback get its turn (D), even though it was scheduled earlier.
The executor, by contrast, runs synchronously, the moment you call new Promise:
console.log("1");
new Promise((resolve) => {
console.log("2: inside executor");
resolve();
}).then(() => console.log("4: then"));
console.log("3");
// → 1
// → 2: inside executor
// → 3
// → 4: then
This consistency is another promise guarantee: a handler is always called asynchronously, so code after .then() always runs before the handler. Lesson 42 covers the event loop, tasks and microtasks in depth.
Avoid wrapping a promise in a new promise
If a function already returns a promise, do not wrap it:
// Avoid
function getTitle(id) {
return new Promise((resolve, reject) => {
loadCourse(id)
.then((course) => resolve(course.title))
.catch(reject);
});
}
// Better
function getTitle(id) {
return loadCourse(id).then((course) => course.title);
}
The second version is shorter, and it cannot accidentally forget to forward an error.
Summary
- A promise is pending, then settles once as fulfilled (with a value) or rejected (with a reason).
- Use
.then()for success,.catch()for failure and.finally()for clean-up. - Create promises with
new Promise((resolve, reject) => ...), mainly to wrap callback APIs. Reject withErrorobjects. - Each
.then()returns a new promise. Return values and promises from handlers to build a flat chain. - A thrown error or rejection skips to the next
.catch(); returning from.catch()recovers. - Always handle rejections. Unhandled rejections are logged in browsers and crash Node.js by default.
- Handlers run as microtasks: after the current synchronous code, before timers. The executor runs synchronously.