I clicked the button. Nothing happened.

No error toast, no console exception I could see, no network failure in red. The row just sat there, exactly as it was before, like the click had landed on dead air. I clicked it again. Still nothing. This is the worst class of bug, because the system isn’t telling you it’s broken. It’s telling you nothing at all, and “nothing” is a much bigger search space than a stack trace.

It took me longer than I want to admit to find it, so here’s the short version up front: my backend was returning a perfectly good JSON error with a 400 status, and IIS was throwing that body in the trash and substituting its own HTML error page on the way out. My frontend then tried to JSON.parse an HTML document, threw, and swallowed the throw. The fix, once I understood it, was the opposite of what every REST tutorial tells you to do: return 200 on failure and put the error in the body.

The setup

I was building inline action endpoints for an internal admin tool. The idea was that instead of opening a ticket in one tab, the related record in another, and a metadata lookup in a third, I could act on a row right where it sat: accept it, comment, reroute, set it aside, one click each, no full page reload. The display was the easy part. The verbs were the work, and the verbs all needed endpoints that did a write and reported back whether it worked.

So I wrote them the way you’re supposed to. A successful action returned 200 with a small JSON payload. A bad request returned 400 with a JSON body explaining what was wrong. A server-side failure returned 500, again with JSON. Status codes carry meaning, the body carries detail, the client branches on the code. Textbook.

The frontend was equally boring:

const res = await fetch(url, { method: "POST", body: payload });
const data = await res.json();
if (data.ok) {
updateRow(data);
} else {
showError(data.message);
}

On the happy path this worked exactly once and I moved on, because of course it worked, why wouldn’t it. The happy path only exercises the one code branch that was never going to break.

The symptom

The first time an action legitimately failed, the button died. No toast. No row update. No visible console error, because the await res.json() rejected inside an async handler and the rejection went nowhere I was looking. From the outside it was indistinguishable from a click that never fired at all.

I did all the wrong things first. I checked the event binding. I added a console.log at the top of the handler, confirmed it fired. I checked that the fetch was actually leaving the browser. It was. I checked the server logs and saw the request arrive, get handled, and return a 400 with my JSON body, exactly as designed. Server said it sent JSON. Client acted like it got nothing. Both ends looked correct in isolation, which is the signature of something chewing on the response in between.

The cause

The thing in between was IIS.

When a response leaves with a non-2xx status code, IIS can replace the response body with its own error page, depending on how httpErrors is configured. The errorMode setting and whether existingResponse is set to PassThrough, Replace, or Auto decide this. On a default-ish config, IIS sees your 400, decides the human deserves a friendly error page, throws away whatever body you carefully wrote, and ships back a chunk of HTML instead. The status code stays 400. The Content-Type flips to text/html. Your JSON is gone, and nothing in the status line tells you it happened.

This is a completely reasonable thing for a web server to do when a browser hits a broken URL directly. A human typing a bad address wants a readable page, not a raw JSON blob. It is poison when the same server is also hosting a JSON API and your client is counting on the body it sent. The exact request that most needs a machine-readable error, the failure case, is the one IIS is most eager to rewrite into HTML.

So the real sequence was: backend returns 400 {"ok": false, "message": "..."}, IIS strips the body and substitutes <!DOCTYPE html>..., the browser hands my fetch a 400 with an HTML body, res.json() hits < at position 0 and throws Unexpected token < in JSON, the throw escapes into an unhandled async rejection, and the button does nothing. Every link in that chain was behaving exactly as designed. The design was the problem.

You can confirm this in seconds once you suspect it. Hit the failing endpoint directly and look at what actually comes back:

curl -i -X POST https://example.test/api/some-action

Look at two lines in the response: Content-Type: text/html and an HTML body opening with <!DOCTYPE html>. If you see those on a request you intended to return JSON, the server in front of your code is rewriting your response. The status code being correct is the trap. The status code is the one thing IIS leaves alone.

The fix

The clean answer would be to tell IIS to leave non-2xx bodies alone. You can do that with httpErrors set to PassThrough in web.config:

<httpErrors errorMode="Custom" existingResponse="PassThrough" />

I tested that and it works in isolation. I didn’t ship it as the only fix. I control my own code, but I don’t control every proxy, CDN, or load balancer that might sit in front of it later, and several of those do the same body-substitution trick on non-2xx responses, each with its own knob I’d have to remember to set. A contract that depends on every intermediary along the path being configured to get out of the way is a contract that breaks the day someone adds an intermediary.

So the fix I actually shipped: every action endpoint returns HTTP 200. Always. Success and failure both come back 200, and the real outcome lives in the body.

{ "status": "ok", "data": { ... } }
{ "status": "error", "message": "Quantity must be a positive integer" }

The client reads status before it does anything else, and never trusts the HTTP code to tell it whether the operation succeeded:

const res = await fetch(url, { method: "POST", body: payload });
const data = await res.json(); // always JSON now, because it's always a 200
if (data.status === "ok") {
updateRow(data.data);
} else {
showError(data.message);
}

Now there is no non-2xx response for any intermediary to feel helpful about. The transport layer carries one fact, “the request reached the app and the app answered,” and the application layer carries the actual verdict. They stop fighting over the same channel.

The part I didn’t expect to like

Returning 200 on a failed write felt wrong when I typed it. It throws away the HTTP status code as a signal, and the status code is genuinely useful: it’s the thing your monitoring, your retry logic, and your browser devtools all key off of. If you’re building a public API that other people’s code consumes, I would not do this. There, the body-substitution problem is the intermediary’s bug to fix, and honoring HTTP semantics is worth more than dodging it.

But this is an internal tool where I own both ends of the wire. And the constraint turned out to force a discipline I now think I should have had anyway: every endpoint has an explicit, in-body success/failure contract that doesn’t depend on the HTTP layer surviving the trip intact. The client never infers success from a 200, it reads it from status. That’s more robust against the whole category of “something in the middle touched my response,” not just IIS’s particular flavor of it. It’s not the design I’d pick on a greenfield system with no proxies in sight. It’s the design that works on the system I actually have.

Verify it the same way you found the bug. Hit a failing action with curl -i and read the response: Content-Type should read application/json, not text/html, and the body should never open with <!DOCTYPE html>. If you see HTML wrapped around a status line that still says 200, something in front of your code is still rewriting the response, and the fix isn’t finished.