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);
})
.then(() => {
setStatus('Saved');
});
}
.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;
})
The better fix is to stop treating "success" as the default state:
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:
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 (opens in a new tab).