🤝 Promise Structure and States
A Promise is JavaScript's way of representing a value that isn't ready yet — a receipt for work still in progress. In this lesson you'll learn the three states every Promise can be in, how to create one with the Promise constructor, and how to consume its result with then, catch, and finally.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a Promise is and why it improves on plain callbacks
- Name and describe the three Promise states — pending, fulfilled, and rejected — and the rules that govern them
- Create Promises with the constructor and with
Promise.resolve/Promise.reject - Consume a Promise with
.then(),.catch(), and.finally() - Promisify a callback-based function so it fits modern async code
Estimated Time: 30–40 minutes • Difficulty: Intermediate
Hands-on: Build a delay() helper and a timeout wrapper from scratch.
In This Lesson
What Is a Promise?
A Promise is an object that represents the eventual result of an asynchronous operation. It's not the value itself — it's a placeholder that will eventually hold either a result (success) or a reason it failed (error).
💡 Restaurant analogy: When you order at a busy counter, you don't stand there holding your food — you get a numbered receipt. That receipt is a promise of a future outcome: either your meal arrives, or a staff member explains why it can't be made. A JavaScript Promise works exactly the same way. You get the receipt immediately and attach instructions for "when the food is ready" and "if something goes wrong."
Before Promises (standardized in ES2015), asynchronous code leaned on nested callbacks, which quickly became the tangled "callback hell" you met in the previous lesson. Promises were designed to fix four specific pain points:
- Readability: operations chain top-to-bottom with
.then()instead of nesting ever deeper. - Centralized error handling: a single
.catch()can catch failures from any earlier step. - Composition: Promises combine cleanly for parallel work (you'll see
Promise.allsoon). - Guarantees: a Promise settles exactly once and never changes afterward — a guarantee raw callbacks never gave you.
They're also the foundation of async/await, the even cleaner syntax you'll meet later in this module. Understanding Promises deeply makes async/await feel obvious.
operation] --> B((Promise
pending)) B -->|success| C[Fulfilled
with a value] B -->|failure| D[Rejected
with a reason] C --> E[.then handler
runs] D --> F[.catch handler
runs]
The Three States
At any moment a Promise is in exactly one of three states:
📖 The states
Pending: the initial state — the async work hasn't finished yet, so there's no value and no error.
Fulfilled: the work succeeded. The Promise now holds a value, and its .then() handlers run.
Rejected: the work failed. The Promise now holds a reason (usually an Error), and its .catch() handlers run.
Once a Promise leaves pending for either fulfilled or rejected, we say it is settled. A settled Promise is frozen: its state and value can never change again, no matter what code runs afterward. This one-way, one-time transition is the core guarantee that makes Promises predictable.
Two more properties are worth committing to memory now — they trip up nearly everyone at first:
- Handlers always run asynchronously. Even if a Promise is already settled, the callback you attach runs after the current synchronous code finishes.
- Resolution is queued as a microtask. Microtasks have priority over regular tasks (like
setTimeout) in the event loop, so Promise callbacks run sooner than a zero-delay timer.
Creating a Promise
The most fundamental way to create a Promise is the constructor. You pass it an executor function that receives two callbacks — resolve and reject:
const myPromise = new Promise((resolve, reject) => {
// Do some asynchronous work here.
const succeeded = true; // pretend this comes from real work
if (succeeded) {
resolve('the result value'); // move to fulfilled
} else {
reject(new Error('what went wrong')); // move to rejected
}
});
The executor runs immediately when the Promise is created. Call resolve(value) to fulfill it, or reject(reason) to reject it. By convention the rejection reason is always an Error object, so you get a useful stack trace.
A realistic example
Here is a Promise that simulates a flaky network call. After a short delay it randomly succeeds or fails:
function simulateApiCall() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const succeeded = Math.random() > 0.5;
if (succeeded) {
resolve({ status: 200, data: { message: 'Operation successful' } });
} else {
reject(new Error('Network error'));
}
}, 1500);
});
}
simulateApiCall()
.then((response) => console.log('Success:', response.data.message))
.catch((error) => console.error('Failed:', error.message));
Already-settled Promises
Sometimes you need a Promise that is already fulfilled or rejected — handy for caching, tests, or keeping a function's return type consistent. Two static shortcuts create them instantly:
const ready = Promise.resolve('cached value');
const broken = Promise.reject(new Error('not allowed'));
ready.then((value) => console.log(value)); // "cached value"
broken.catch((reason) => console.log(reason.message)); // "not allowed"
⚠️ Don't do work outside the executor by accident
The executor is the only place you should call resolve/reject. If your async work (like a setTimeout or an event listener) lives outside the executor, the Promise can never settle and stays pending forever — a common source of "my .then() never runs" bugs.
Consuming a Promise: then, catch & finally
Once you hold a Promise, you read its outcome by attaching handlers. There are three methods, and each one returns a new Promise — which is what makes chaining possible (the topic of the next lesson).
.then(onFulfilled, onRejected)
Attaches a success handler and, optionally, a failure handler. Whatever the handler returns becomes the fulfillment value of the next Promise in the chain; whatever it throws becomes the next rejection.
promise.then(
(value) => {
console.log('Success:', value);
return value * 2; // becomes the next .then's value
},
(reason) => {
console.error('Error:', reason);
}
);
.catch(onRejected)
A shorthand for .then(null, onRejected). It handles rejections and is almost always placed at the end of a chain so it can catch errors from any earlier step.
promise.catch((reason) => {
console.error('Something failed:', reason.message);
return 'fallback value'; // recover: the chain continues fulfilled
});
.finally(onSettled)
Runs a callback when the Promise settles, regardless of success or failure, and receives no arguments. Perfect for cleanup — hiding a loading spinner, closing a connection — just like the finally block in a try/catch.
showSpinner();
fetchData()
.then((data) => render(data))
.catch((err) => showError(err))
.finally(() => hideSpinner()); // always runs
Putting it together
A typical real-world flow: fetch a user, use the result to fetch their posts, handle any error centrally, and always clean up.
function fetchUser(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId > 0) {
resolve({ id: userId, name: `User ${userId}` });
} else {
reject(new Error('Invalid user ID'));
}
}, 800);
});
}
const spinner = document.getElementById('loading');
spinner.hidden = false;
fetchUser(42)
.then((user) => {
console.log('Got user:', user.name);
return fetch(`/api/users/${user.id}/posts`).then((r) => r.json());
})
.then((posts) => console.log('Posts:', posts))
.catch((error) => console.error('Flow failed:', error.message))
.finally(() => { spinner.hidden = true; });
How Promises Behave
Handlers run asynchronously
Attaching a .then() to an already-resolved Promise does not run the handler immediately. It's scheduled as a microtask and runs after the current synchronous code:
console.log('Start');
Promise.resolve('now').then((v) => console.log('Promise:', v));
console.log('End');
// Output order:
// Start
// End
// Promise: now
Settling is permanent
Once a Promise settles, later resolve/reject calls are silently ignored:
const p = new Promise((resolve, reject) => {
resolve('first');
resolve('second'); // ignored
reject(new Error('too late')); // ignored
});
p.then((v) => console.log(v)); // always "first"
Console output
first
Errors propagate down the chain
A thrown error (or a rejected Promise) skips every following .then() until it reaches a .catch(). After the catch recovers, the chain continues normally:
Promise.resolve('start')
.then((v) => { throw new Error('boom'); })
.then((v) => console.log('skipped', v)) // skipped
.catch((err) => {
console.error('Caught:', err.message);
return 'recovered';
})
.then((v) => console.log('After recovery:', v));
// Caught: boom
// After recovery: recovered
⚠️ The #1 chain-breaking mistake
Forgetting to return inside a .then() breaks the chain — the next handler receives undefined instead of the value you expected. Always return the value or Promise you want to pass along.
Promisifying Callback APIs
A huge amount of older JavaScript uses the "error-first callback" style: fn(args, (error, result) => { ... }). You can wrap any such function in a Promise so it works with modern .then() chains and async/await. This pattern is called promisification.
// Legacy callback-based function
function readFileCallback(path, callback) {
setTimeout(() => {
if (path.endsWith('.txt')) {
callback(null, `Content of ${path}`);
} else {
callback(new Error('Only .txt files are supported'));
}
}, 500);
}
// Promise-based wrapper
function readFile(path) {
return new Promise((resolve, reject) => {
readFileCallback(path, (error, content) => {
if (error) reject(error);
else resolve(content);
});
});
}
readFile('notes.txt')
.then((content) => console.log(content))
.catch((err) => console.error(err.message));
💡 In real projects
Node.js ships util.promisify() to do this automatically for standard-shaped callback functions, and most modern libraries already return Promises. But knowing how to hand-roll the wrapper means you can bridge any callback API you meet.
Hands-on Exercise
🏋️ Build a delay() helper and a timeout wrapper
Objective: Practice the constructor and Promise.race by building two small utilities every developer eventually needs.
Instructions:
- Write
delay(ms): it returns a Promise that fulfills (with no value) aftermsmilliseconds. - Test it:
delay(1000).then(() => console.log('one second later')). - Write
withTimeout(promise, ms): it returns a Promise that rejects with a timeout error ifpromisehasn't settled withinms, otherwise passes through its result. (Hint: race the input Promise against a rejectingdelay.) - Test it against a slow simulated fetch to confirm the timeout fires.
💡 Hint
delay just wraps setTimeout and calls resolve in the callback. For withTimeout, build a second Promise that rejects after ms, then hand both Promises to Promise.race([...]) — whichever settles first wins.
✅ Solution
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]);
}
// Test
delay(1000).then(() => console.log('one second later'));
const slow = new Promise((resolve) => setTimeout(() => resolve('done'), 4000));
withTimeout(slow, 2000)
.then((result) => console.log('Result:', result))
.catch((error) => console.error('Error:', error.message)); // Timed out after 2000ms
🎯 Quick Quiz
Question 1: A Promise has just moved from pending to fulfilled. What happens if its executor later calls reject()?
Question 2: Which method's callback runs whether the Promise fulfills or rejects, and receives no arguments?
Question 3: What is the output order of console.log('A'); Promise.resolve().then(() => console.log('B')); console.log('C');?
Best Practices
✅ Do
- Reject with an
Errorobject, not a string — you get a stack trace. - End every chain with a
.catch()so no rejection goes unhandled. returnthe value or Promise you want the next handler to receive.- Use
.finally()for cleanup that must happen either way.
⚠️ Don't
- Don't call
resolve/rejectoutside the executor — the Promise will hang. - Don't forget to
returninside.then(); the chain silently loses the value. - Don't nest Promises when you can chain them flat.
- Don't rely on a settled Promise "resetting" — it never will.
Summary & Quiz
🎉 Key Takeaways
- A Promise is a placeholder for a future value — a receipt for asynchronous work.
- Its three states are pending, fulfilled, and rejected; once settled it is frozen forever.
- Create Promises with
new Promise((resolve, reject) => …)or the shortcutsPromise.resolve/Promise.reject. - Consume them with
.then(),.catch(), and.finally()— each returns a new Promise. - Handlers run asynchronously as microtasks, and errors propagate down the chain to the nearest
.catch().
📚 Further Reading
🚀 What's Next?
You can now create and consume a single Promise. Next we'll link many of them together into readable pipelines with Promise chaining and composition — turning nested callbacks into a clean, flat flow.
🎉 Well done!
The receipt makes sense now. Let's line several of them up into a chain.