The bug that taught me the most about promises was not a crash. It was a silent wrong screen that loaded once every few thousand sessions. A promise resolved with a stale user profile, and my handler ran, but I had chained the next step in a way that skipped an error path. That one bug pushed me to get very precise about then(): what it does, what it returns, and how it schedules work. If you rely on promises for anything real, the details are the difference between code that feels solid and code that surprises you during peak traffic.
I will show you how I think about then() as a contract between a settled promise and the next step, how success and failure travel through a chain, and how to design chains that are readable and safe. You will see runnable examples, the common mistakes I see in reviews, and the decision points where I reach for then() instead of async/await. I will also point out the edge cases that matter in modern apps, like handler timing and thenable adoption. If you have ever wondered why a promise chain behaved differently than you expected, this will help you reason about it with confidence.
Why then() still matters in 2026
Promises have been in JavaScript for a long time, and async/await made them feel friendly. That does not make then() optional. Under the hood, await is just syntax sugar for a promise chain, so understanding then() is still the fastest way to debug timing, error propagation, and unexpected return values.
I also see then() in places where await is not practical. One example is functional pipelines where I want a small, testable function that returns a promise without forcing the caller into an async context. Another is when I need to build a chain dynamically, perhaps based on configuration or feature flags. then() makes it obvious that each step is a transformation, and it keeps the flow explicit.
Think of a promise as a courier package that will eventually arrive. then() is the doorbell that tells you when it arrives, and the handler is the person who signs for it. You can also hand it to someone else and say, "When it arrives, pass it to this other person with these instructions." That handoff is the new promise that then() returns. Once you see then() as a handoff contract, a lot of confusing behavior becomes predictable.
The contract of then(): what it accepts and what it returns
The then() method is called on a promise and accepts up to two callbacks: one for fulfillment and one for rejection. The standard signature is:
then(successFunc, rejectFunc)
The success callback receives the resolved value. The rejection callback receives the reason (usually an Error, but it can be any value). The key part is the return value: then() always returns a new promise. That new promise starts pending even if the original promise is already settled. It resolves or rejects based on what your handlers do.
Here is the contract I use when reasoning about chains:
1) If the success handler returns a value, the new promise resolves to that value.
2) If the success handler throws, the new promise rejects with that thrown error.
3) If the success handler returns a promise, the new promise adopts that promise‘s eventual state.
4) If the rejection handler returns a value, the new promise resolves to that value (this often surprises people).
5) If the rejection handler throws, the new promise rejects with that error.
6) If you omit a handler, the value or error passes through unchanged.
That adoption behavior is what makes then() chainable. It also means you can create chains that transform data step by step, like a relay race where each runner hands a baton to the next runner. Each then() is a runner. The returned promise is the baton.
There is one more nuance I always keep in mind: if you pass non-functions as handlers, they are ignored. That means then(null, onReject) is valid, and then(undefined, onReject) is effectively the same. I lean on that when I want to handle failure in one place without affecting success.
Handling success and failure with two callbacks
The simplest way to use then() is to handle a resolved promise. Here is the classic example, made runnable and explicit:
const prom1 = new Promise((resolve, reject) => {
resolve("Success");
});
prom1.then(result => {
console.log("Hello Successful");
});
You will see:
Hello Successful
If you want to handle rejection directly in then(), pass a second function. This is not my default choice, but it is valid:
const prom2 = new Promise((resolve, reject) => {
reject("Rejected");
});
prom2.then(
result => {
console.log("Hello Successful");
},
error => {
console.log(error);
}
);
You will see:
Rejected
Why do I usually prefer catch() instead of the second argument? Two reasons. First, it keeps success and failure handling separate, which makes chains easier to read. Second, it avoids a subtle trap: if you pass a rejection handler to then() and that handler returns a value, the chain becomes resolved afterward, which can hide errors from later catch() blocks. That can be fine, but you should decide it on purpose.
If you do use the second argument, use it when you truly want to recover at that point in the chain. For example, if a non-critical analytics call fails, you might convert that failure into a fallback value and let the chain keep moving:
sendAnalytics(eventData)
.then(
() => "sent",
error => {
console.warn("analytics failed", error);
return "skipped"; // recover and keep the chain resolved
}
)
.then(status => {
console.log("analytics status", status);
});
That is not wrong, but it is a deliberate decision to recover. If you want the error to propagate, omit the reject handler and add a catch() later.
Chaining, transforming, and branching with then()
Chaining is where then() really shines. Each handler can return a value or another promise, and the next then() will receive that result. Here is a simple chain that mirrors how I often model workflow steps:
const prom3 = new Promise((resolve, reject) => {
resolve("Successful");
});
prom3
.then(result => {
console.log(result);
return "Completed";
})
.then(next => {
console.log(next);
});
Output:
Successful
Completed
That example looks simple, but it illustrates the core idea: each then() returns a new promise that waits for the handler to finish. If the handler returns a value, the next step gets that value. If the handler returns a promise, the chain pauses until that promise settles.
Here is a more realistic example that reads user data, enriches it, and renders it. Each step is a focused transformation, which makes it easier to test:
function fetchUserProfile(userId) {
return fetch(/api/users/${userId})
.then(res => {
if (!res.ok) throw new Error("Profile fetch failed");
return res.json();
});
}
function enrichProfile(profile) {
return fetch(/api/flags/${profile.accountId})
.then(res => res.json())
.then(flags => {
return {
...profile,
flags,
displayName: ${profile.firstName} ${profile.lastName}
};
});
}
function renderProfile(profile) {
document.querySelector("#name").textContent = profile.displayName;
document.querySelector("#tier").textContent = profile.flags.tier;
return profile;
}
fetchUserProfile("u-2048")
.then(enrichProfile)
.then(renderProfile)
.catch(error => {
console.error("Profile pipeline failed", error);
showErrorBanner("We could not load your profile.");
});
A few notes from experience:
- I return values even from handlers that mostly do side effects. That makes it easier to keep the chain going or to add a new step later.
- I throw errors early. Throwing inside a
then()handler is the cleanest way to move to the rejection path. - I keep each step small. The chain reads like a narrative and makes debugging much faster.
If you need branching, you can conditionally return different values or promises. The contract stays the same, so the next then() does not care which branch ran.
Common mistakes I see (and how I avoid them)
I review a lot of promise code, and a handful of mistakes show up again and again. Here is how I address them.
1) Forgetting to return in a handler
If you forget to return a promise inside a then(), your chain will not wait for it. The next step will run immediately with undefined. I fix this by returning every promise I create in a handler, even for side effects.
Bad:
updateAccount(accountId)
.then(() => {
logAuditEvent(accountId); // missing return
})
.then(() => {
// runs before logAuditEvent finishes
});
Good:
updateAccount(accountId)
.then(() => {
return logAuditEvent(accountId);
})
.then(() => {
// runs after logAuditEvent finishes
});
2) Swallowing errors with the second argument
If you pass a rejection handler to then() and it returns a value, the chain becomes resolved. That may hide errors from later catch() blocks. I use the second argument only when I want to recover intentionally and I am sure I want to continue.
3) Throwing non-Error values
You can reject with strings, but I prefer Error objects because they preserve stack traces and are easier to handle. If you need structured data, attach it to the error.
4) Assuming then() runs immediately
then() callbacks run on the microtask queue. That means they run after the current call stack clears, even if the promise is already resolved. If you rely on immediate execution, your code will behave strangely.
Example:
const ready = Promise.resolve("ready");
console.log("start");
ready.then(() => console.log("then"));
console.log("end");
Output:
start
end
then
5) Mixing await and then() without clear intent
I have seen code like await fetch().then(...). That is usually a code smell. If you start with await, keep it in async/await. If you start with then(), keep it in a chain. Mixing them often signals confusion.
When I choose then() vs async/await
I use both styles. I recommend you pick one per function to keep code predictable. Here is the rule of thumb I follow:
- I use
then()for pipelines, small utilities, or when I want to return a promise withoutasyncoverhead. - I use
async/awaitwhen the logic needs branching, loops, or error handling that reads better in a synchronous style.
Here is a simple comparison table to make the decision practical.
Traditional style
—
Nested then() blocks
async function with await and early returns Imperative loops
then() chain with small pure functions then(success, reject)
catch() at the end of a chain or try/catch with await Callback style
then() or await Manual mocks
If you are writing a library function, returning a promise is the most flexible option. The caller can use then() or await based on their codebase style. If you are building app logic in a codebase that already favors async/await, I suggest you keep the style consistent.
Real-world patterns, edge cases, and performance notes
then() is not just for toy examples. Here are patterns I use in production and the edge cases that keep me honest.
Parallel work with Promise.all
If you need to fetch multiple independent resources, you can run them in parallel and then handle the result with a single then().
const productId = "p-7712";
Promise.all([
fetch(/api/products/${productId}).then(r => r.json()),
fetch(/api/reviews/${productId}).then(r => r.json()),
fetch(/api/inventory/${productId}).then(r => r.json())
])
.then(([product, reviews, inventory]) => {
renderProductPage({ product, reviews, inventory });
})
.catch(error => {
showErrorBanner("We could not load the product page.");
console.error(error);
});
This is faster than sequential fetches for independent resources. In many apps, this is the largest visible speed improvement you can get with minimal code changes. When I profile real pages, parallel fetches often save tens of milliseconds to a few hundred milliseconds, depending on network conditions.
Controlled sequential flow with data dependencies
When each step depends on the previous result, then() makes the dependency chain explicit:
createOrder(cart)
.then(order => reserveInventory(order))
.then(reservation => chargePayment(reservation))
.then(receipt => sendReceiptEmail(receipt))
.catch(error => {
console.error("checkout failed", error);
rollbackCheckout();
});
Each step returns a promise, and the chain waits. I prefer this over manual state machines for straightforward flows.
Timeouts and cancellation patterns
Promises do not have built-in cancellation, so if I need timeouts I race against a timer. then() works well here:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
const id = setTimeout(() => {
clearTimeout(id);
reject(new Error("Timeout"));
}, ms);
});
return Promise.race([promise, timeout]);
}
withTimeout(fetch("/api/report"), 5000)
.then(res => res.json())
.then(report => renderReport(report))
.catch(error => showErrorBanner(error.message));
This does not stop the underlying request, but it gives you a clean failure path. If you need true cancellation, pair your requests with AbortController and reject when aborted.
Thenables and interoperability
Any object with a then method can behave like a promise. This is convenient when integrating older libraries or custom async abstractions. It also means you should be careful not to pass random objects with a then property into promise chains unless that is your intent.
Microtask timing and UI updates
Because then() callbacks run in the microtask queue, they execute after the current call stack but before the next macrotask. That matters for UI updates. If you set state and immediately read the DOM, you might not see the changes you expect if the update is in a promise chain. I keep UI measurements in requestAnimationFrame when I need accurate layout data.
Browser support
All modern browsers support Promise and then(). If you target very old environments, you may need a polyfill, but for current desktop and mobile browsers this is standard behavior.
Common mistakes in chaining and how I test against them
I want to call out a few subtle chain behaviors that are easy to miss, plus the small tests I use to verify them.
Returning a promise vs returning a value
If you return a promise, the next handler waits. If you return a value, the next handler runs with that value immediately (on the microtask queue). I test these behaviors with a quick timing check:
const start = Date.now();
Promise.resolve("seed")
.then(() => new Promise(resolve => setTimeout(resolve, 50)))
.then(() => {
const elapsed = Date.now() - start;
console.log("elapsed", elapsed); // usually around 50ms+
});
Throwing inside a handler
Throwing is the same as rejecting. I test this with a simple chain and a final catch():
Promise.resolve("ok")
.then(() => {
throw new Error("boom");
})
.then(() => {
console.log("this will not run");
})
.catch(error => {
console.log(error.message); // boom
});
Returning undefined on purpose
Sometimes I return undefined on purpose to signal that a step is for side effects only. In those cases, I add a comment so the reader does not assume a missing return was a mistake.
saveAuditLog(event)
.then(() => {
// side effects only; no value needed
return undefined;
})
.then(() => {
// next steps
});
Comments like that are tiny, but they save review time.
A final perspective I want you to carry forward
Promises are not just a syntax feature. They are a coordination model, and then() is the handshake that keeps that model honest. When you think about then() as a contract, you can reason about complex async flows with clarity: what value moves forward, what errors are recovered, and when the next step runs.
The practical takeaway I use in my own work is simple. If you want a pipeline of transformations, then() reads like a story and makes failures explicit. If you want a synchronous-looking flow with branching and loops, async/await is usually easier to scan, but it still depends on the same promise rules. Either way, you are building on the then() contract.
Your next steps should be concrete. Pick a promise chain in your current codebase and rewrite it as small steps that each return a value or a promise. Add a single catch() at the end and see whether the error flow feels clearer. Then run a quick test with a forced failure and watch the chain behavior. I do this whenever I refactor async flows, and it prevents subtle mistakes.
If you keep one rule in your head, make it this: a then() handler is a transformation, and the return value is the baton passed to the next runner. Keep that baton clean, and your async code will stay understandable long after the feature ships.


