Promises and Chaining
The Core Problem#
A phone login with an OTP has three steps: send the OTP, verify what the user typed, then create a session. Each step takes time and can fail. Written with callbacks, that is three nested levels with an error check at each one. You have seen where that leads.
A promise lets you write the same flow as a flat chain, with a single place to handle every failure. In this post you will learn what a promise is, its three states, and how chaining works, including the one missing keyword that breaks most chains.
By the end you will be able to:
- Create a function that returns a promise
- Explain pending, fulfilled and rejected
- Chain steps with
.then, handle failures with.catch, and spot the missingreturnbug
Mental Model & Intuition#
Think of the token you get at a food counter. The token is not your food. It is a receipt that says "your order will be ready, or we will tell you why it cannot be."
A promise is that token. It is an object that stands for a result that has not arrived yet. It is always in one of three states:
| State | Meaning | How it gets there |
|---|---|---|
| pending | Work is still running | The starting state |
| fulfilled | Work succeeded and a value is available | Your code calls resolve(value) |
| rejected | Work failed and an error is available | Your code calls reject(error) |
Fulfilled and rejected are called settled. A settled promise never changes again.
You attach handlers to a promise:
.then(fn)runs when the promise is fulfilled.catch(fn)runs when the promise is rejected.finally(fn)runs either way, which is useful for cleanup such as hiding a loader
The chaining rule is the heart of this topic: every .then returns a new promise. If your handler returns a plain value, the next .then receives it. If your handler returns a promise, the next .then waits for it to settle.
Promises replace the callback pattern. Instead of passing a callback into a function, the function returns a promise and you attach your handlers to it.
Step-by-Step Walkthrough#
Here is what happens in the login chain from the code section, step by step.
| Step | State | Why |
|---|---|---|
| 1 | sendOtp returns a promise that is pending |
JavaScript does not wait, the chain is registered |
| 2 | After 500 ms, the promise is fulfilled with the OTP | The first .then can now run |
| 3 | The first .then prints "OTP sent" and returns verifyOtp(...) |
Returning a promise makes the next .then wait for it |
| 4 | verifyOtp is fulfilled, so the second .then runs and returns createSession(...) |
Each step starts only after the previous one succeeds |
| 5 | The token arrives and the third .then prints it |
The final value flows down the chain |
| 6 | If any step is rejected, execution jumps straight to .catch |
The remaining .then handlers are skipped |
| 7 | .finally runs in every case |
Success or failure, cleanup happens |
Code Implementation#
1. Creating promises#
Each function returns a new Promise. Inside, we call resolve on success and reject on failure.
function sendOtp(phone) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (phone.length !== 10) {
return reject(new Error("Invalid phone number"));
}
resolve("4821");
}, 500);
});
}
function verifyOtp(expected, entered) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (expected !== entered) return reject(new Error("Wrong OTP"));
resolve(true);
}, 500);
});
}
function createSession(phone) {
return new Promise((resolve) => {
setTimeout(() => resolve("token_" + phone.slice(-4)), 300);
});
}
You can see the states directly:
const p = sendOtp("9876543210");
console.log(p);
p.then((otp) => console.log("OTP:", otp));
sendOtp("123").catch((err) => console.log("Failed:", err.message));
Output:
Promise { <pending> }
OTP: 4821
Failed: Invalid phone number
2. Chaining the login flow#
const phone = "9876543210";
sendOtp(phone)
.then((otp) => {
console.log("OTP sent");
return verifyOtp(otp, otp);
})
.then(() => {
console.log("OTP verified");
return createSession(phone);
})
.then((token) => {
console.log("Logged in:", token);
})
.catch((err) => {
console.log("Failed:", err.message);
})
.finally(() => {
console.log("Done");
});
Output when the user types the correct OTP:
OTP sent
OTP verified
Logged in: token_3210
Done
Change verifyOtp(otp, otp) to verifyOtp(otp, "0000") to simulate a wrong OTP:
OTP sent
Failed: Wrong OTP
Done
Notice that "OTP verified" and "Logged in" never print. The failure skipped every .then and landed in .catch.
3. The same flow in two styles#
Here is a preview of what the next post covers. Both tabs do exactly the same thing.
Edge Cases & Gotchas#
Forgetting return inside .then breaks the chain. Without it, the next .then does not wait for your promise. It runs immediately with undefined.
sendOtp(phone)
.then((otp) => {
console.log("OTP sent");
verifyOtp(otp, "0000"); // no return
})
.then((result) => {
console.log("Next step got:", result);
return createSession(phone);
})
.then((token) => console.log("Logged in:", token))
.catch((err) => console.log("Failed:", err.message));
Output:
OTP sent
Next step got: undefined
Logged in: token_3210
The user logged in even though the OTP was wrong. Worse, the failed verifyOtp promise has no handler attached, so Node also crashes with an unhandled rejection error. This is the most common promise bug.
A rejected promise with no .catch crashes Node. Since Node 15, an unhandled rejection stops the process with an error. Always end a chain with .catch, or use try/catch with async/await.
A .catch in the middle recovers the chain. If the handler returns a value, the chain continues as if nothing went wrong.
sendOtp("123")
.catch(() => "fallback value")
.then((value) => console.log("Chain continues with:", value));
Output:
Chain continues with: fallback value
Use this on purpose for fallbacks. Do it by accident, and failures silently disappear.
Reject with an Error object, not a plain string. reject(new Error("Wrong OTP")) gives you a message and a stack trace. reject("Wrong OTP") gives you neither, which makes debugging much harder.
Pro Tip / Performance Insight#
Use new Promise only to wrap old callback style code or timers. If a function already returns a promise (as fetch and axios do), do not wrap it inside another new Promise. Just return it, or chain it.
Use .finally for cleanup that must always happen, such as hiding a loading spinner or closing a connection. It runs whether the chain succeeded or failed, so you write the cleanup once.
Next Steps & Practice#
Try these on your own before moving on:
- Write
checkWallet(balance, price)that returns a promise. It should resolve with the remaining balance, or reject with anErrorwhen the balance is too low. - Take the ticket booking pyramid from the previous post and rewrite it as a flat chain with a single
.catch. - Remove one
returnfrom your chain on purpose. What do you see in the output? Put it back and confirm the fix.
Read next: async/await and Error Handling, where the same flow reads like ordinary top to bottom code.