---
title: "The Day I Taught Failures to Speak, Everything Started Shouting"
canonical: https://dxdev.com/blog/2026-09-30_giving-silent-failures-a-voice/
datePublished: 2026-09-30
---
A customer reported a dead Save button on Locations and Fields about two hours after we shipped a fix for a dead Save button on bracket teams. So I checked whether our fix was involved.

## The wrong turn

It wasn't. That fix only guarded the team-slots save, and locations go through a different function.

Then I went looking for what was underneath, and found something that looked like a smoking gun. Every bracket-manager save funnels through one `$.post` in `Submit_Save_BracketInfo`, and it has no error handling at all. If the post fails, or comes back as anything other than JSON, the success callback never runs. No message, the dialog stays open, the saving overlay stays up, and the edit is thrown away. About 60 call sites share that one post, which explained why the same symptom kept surfacing on different pages.

I reproduced it on a test tournament by making the save return HTML instead of JSON, which is roughly what a dropped session looks like to the browser. I wrote it up as the root cause and started adding a failure path to that one post.

That was the mistake. The cost was a confident comment on the ticket that I then had to retract.

## What the IIS log said

The thing that broke the theory was the log for that day. Everything below is UTC, and the customer's local time is four hours earlier.

```
19:05:09  POST /admin/ajax/PrefSave.asp          302 -> ?p=login&error=SessionExpired
19:07:43  POST /api/features/locations/          302 -> ?p=login&error=SessionExpired
19:07:45, 19:07:50, 19:08:21, 19:08:22, 19:09:23, 19:09:25, 19:09:32  same, all 302
19:12:12  twelve PrefSave POSTs in one second, all 302
19:12:41  POST /tournament/ajax/Tournament.asp   302 -> ?p=login&error=SessionExpired
19:13:28  POST /ajax/Login.asp                   logs back in
19:20:22  POST /tournament/ajax/Tournament.asp   200, saving fine again
```

Their session had expired. The eight Locations save attempts they reported were the 302s at 19:07 to 19:09, and the server was right to refuse every one. They emailed us at 15:12 their time, and saving "started working again" because they logged back in a minute later. Nothing we shipped had anything to do with it.

The bug we owned was that nobody told them.

## Why every page was silent

`AjaxError` is wired up globally through `$.ajaxSetup`, so it runs on every failed ajax call in the admin. It built an error message and then threw it away, because the alert line was commented out. The only other output was `$('#ajaxError').html()`, and that element is rendered on exactly one page in the product. So every failed save, everywhere in the admin, had been completely silent. That is why the same report kept arriving against a different page each time.

My per-function fix would have patched one save path and left the rest of that surface mute. The fix belonged in the shared handler, where it covers every endpoint at once. It detects the expired-session signature in the response and says so specifically, with the login page one click away. Anything else gets a plain "that did not save". It is deduped, because one failing click can fire a dozen calls, and the customer's burst would have stacked twelve dialogs.

The bracket-save guard stayed. It handles the overlay cleanup and synchronous throws, which never reach the ajax layer.

## Then everything started shouting

On a team inside a league, three pages each background-GET `/teams/ajax/Schedule.asp` with the team's username: the admin schedule page, the dashboard My Schedule module, and bulk score entry. The visitor site redirects that request to the league's page, so the JSON parse fails. Before the ticket that was silent. After it, every page load on those sites showed a wrong "That did not save (parsererror)" dialog. A teammate hit it on the schedule page within hours of the release.

I shipped two follow-ups.

- **First follow-up:** The three background loads got their own local error handler that clears the loading spinner and logs a console line, with no dialog. Real save failures kept the alert. I verified it live on the page the teammate had reported.
- **Second follow-up:** This one was the real fix, where the previous one had only made the failure quieter. When the direct schedule call fails on a team inside a league, the callers retry through the league site with `div=<team>`. The endpoint has always supported that, and it is the same path the league's public schedule team filter uses. On team-in-league sites the calendar filter rendered for the first time, and the dashboard Upcoming Schedule module started showing the team's games.

## The shadow handlers

An overnight pass to remove local `AjaxError` overrides turned up four admin pages, not the three I had named, each with its own copy of the error box. They quietly took over from the shared handler, so nobody saw the session-timeout message there. Roster's was the worst. It wrote into a box most Roster pages don't have, so it showed nothing at all. I removed the copies from Colors, Locations and Roster, and gave the photo upload popup the message directly since it runs independently. I tested each with a forced timeout.

The last piece was the tab itself. On focus, a tab now asks the server how long the session has actually been idle, so a backgrounded or sleeping tab gets a real answer instead of a stale timer. A tab left open past the sign-out window takes you to the login page when you come back, instead of looking signed in until a save fails.

## The noise is the new backlog

A global error handler is a measurement instrument. Once the handler spoke, it surfaced three background loads that had been failing quietly, and the four shadow handlers were already eating the message. When you give failures a voice, expect the noise, and treat the noise as the new backlog.
