A save button calls an API, the request fails, the error appears in the console - and the UI still shows 'Saved'. Walk me through what is actually happening in that promise chain.
A catch handler that returns normally converts a rejected promise into a fulfilled one, so every then after it runs as though nothing failed. Combined with fetch, which only rejects on network errors and resolves happily on a 500, the chain reports success twice over. Fix by re-throwing from catch, checking response.ok, and modelling UI state as a machine.
What the interviewer is scoring
- Whether the candidate knows a catch handler that returns a value produces a fulfilled promise, resuming the chain
- That they notice fetch does not reject on 4xx or 5xx, and check response.ok
- Does the answer explain that then and catch each return a new promise, rather than mutating the original
- Whether the position of catch in the chain is identified as significant
- That re-throwing or returning a rejected promise is offered, not just moving the catch
- Whether the candidate treats the boolean success flag as a design problem, not only a bug
- Does the answer mention floating promises or unhandled rejections as the related failure
Answer
Short answer
catch does not stop a chain — it handles the rejection, and a handler that returns normally produces a fulfilled promise. Every .then after it therefore runs on the success path. The error was genuinely caught, genuinely logged, and then genuinely swallowed, and the chain continued as if the call had worked.
The bug in eight lines
function save(draft) {
return fetch('/api/drafts', { method: 'POST', body: JSON.stringify(draft) })
.then(res => res.json())
.catch(err => {
console.error('save failed', err); // returns undefined
}) // -> chain is now FULFILLED with undefined
.then(() => {
setStatus('Saved'); // runs regardless
});
}
.then and .catch do not modify the promise they are called on; each returns a new promise. The catch here returns a promise fulfilled with undefined, because its handler completed without throwing. The following .then sees a fulfilled promise and does what it was told to do.
This is not a quirk. It is the whole point of catch: it exists so a chain can recover from a failure and carry on. fetchFromCache().catch(() => fetchFromNetwork()) depends on exactly this behaviour. The bug is using a recovery construct where you meant to abort.
The second failure hiding in the same chain
Even with the catch removed, this code would report success on a server error. fetch only rejects on network-level failures — DNS failure, connection refused, CORS rejection, request aborted. A 500 Internal Server Error is a perfectly successful HTTP exchange as far as fetch is concerned: the promise fulfils with a Response whose ok is false.
So the API returns 500, res.json() parses the error body without complaint, and the chain proceeds to setStatus('Saved') having never encountered a rejection at all. You have to opt in:
.then(res => {
if (!res.ok) throw new HttpError(res.status, res.statusText);
return res.json();
})
Candidates who spot only the catch have found half the bug. This is usually the half that matters in production, because 500s are far more common than dropped connections.
Fixing the chain
The minimal fix is to re-throw, which keeps the logging and lets the rejection propagate:
.catch(err => {
console.error('save failed', err);
throw err; // or: return Promise.reject(err)
})
The better fix is to stop treating "success" as the default state:
function save(draft) {
setStatus('saving');
return fetch('/api/drafts', { method: 'POST', body: JSON.stringify(draft) })
.then(res => {
if (!res.ok) throw new HttpError(res.status, res.statusText);
return res.json();
})
.then(saved => {
setStatus('saved'); // only reachable from the success path
return saved;
})
.catch(err => {
setStatus('error'); // the failure path sets its own state
report(err);
throw err; // callers still learn it failed
});
}
Two things changed and both matter. setStatus('saved') is now inside a .then that only a successful response can reach, so no handler can fall through into it. And the catch is terminal for the UI but still re-throws, so a caller that wanted to know — a form that should stay open, a queue that should retry — is not lied to.
Where the catch sits changes what it covers
.then(onOk).catch(onErr) catches rejections from the original promise and any error thrown inside onOk. .then(onOk, onErr) catches only the former — a throw inside onOk sails past its sibling handler. That asymmetry is occasionally what you want, when a rendering error should not be reported as a network error, but the two-argument form is the more common source of confusion, so the chained .catch is the better default.
The same bug in async/await
Nothing changes except that it becomes easier to see:
async function save(draft) {
try {
const res = await fetch('/api/drafts', { method: 'POST', body: JSON.stringify(draft) });
if (!res.ok) throw new HttpError(res.status, res.statusText);
return await res.json();
} catch (err) {
console.error('save failed', err);
// no throw here == the caller receives undefined and treats it as success
} finally {
setStatus('saved'); // <-- and finally always runs, including on failure
}
}
try/catch has exactly the same semantics: a catch block that does not re-throw makes the function return normally. The finally variant is worth calling out because it looks like tidy cleanup and is a reliable way to reintroduce the bug — finally runs on both paths, so anything in it that asserts success is wrong by construction.
The related failure: nobody is awaiting
If the caller invokes save(draft) without await or .then, the returned promise floats. The re-thrown error has no handler, so it surfaces as an unhandledrejection — visible in the console and in error tracking, but the calling code has already moved on and the UI state is whatever the last handler set. Whether that shows as success depends on timing, which is why this class of bug is often reported as intermittent.
Catching it without relying on review
Three mechanisms do most of the work. TypeScript with @typescript-eslint/no-floating-promises fails the build on any promise nobody consumes. A single fetch wrapper that throws on !res.ok means no call site has to remember. And modelling status as a union — 'idle' | 'saving' | 'saved' | 'error' rather than a boolean — makes the impossible states unrepresentable, so a handler cannot leave the UI claiming success it never observed. Proposing the wrapper and the type, rather than only fixing the one chain, is what turns a debugging answer into a design one.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- Move the catch to the very end of the chain. Does that fix it, and what does it change about error granularity?
- What is the difference between .then(onOk, onErr) and .then(onOk).catch(onErr)?
- The same code written with async/await - where does this bug live there?
- You use Promise.all for three saves and one fails. What does the user see, and what do you want them to see?
- How would you catch this class of bug automatically rather than in review?
Related questions
- A promise rejects and nothing is awaiting it. What does Node do?hardAlso on promises and async4 min
- Here is a script mixing setTimeout, a promise chain and an await. Tell me the order the logs come out in, and why.mediumAlso on promises and javascript4 min
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumAlso on async4 min
- How is `this` determined in JavaScript, and why does a method lose it when you pass it as a callback?mediumAlso on javascript4 min