A customer reported a dead Save button on Locations about two hours after we shipped a fix for a dead Save button on another admin page. So we checked whether our fix was involved.

The wrong turn

It wasn’t. That fix only guarded one save, and locations go through a different function.

Then we went looking for what was underneath, and found something that looked like a smoking gun. Every manager save funnels through one $.post in Submit_Save_EventInfo, 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.

We reproduced it on a test event by making the save return HTML instead of JSON, which is roughly what a dropped session looks like to the browser. We 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 we 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 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 /event/ajax/Event 302 -> ?p=login&error=SessionExpired
19:13:28 POST /ajax/Login logs back in
19:20:22 POST /event/ajax/Event 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.

Our 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 save guard stayed. It handles the overlay cleanup and synchronous throws, which never reach the ajax layer.

Then everything started shouting

On an account that sits inside an organization, three pages each background-GET /accounts/ajax/Schedule with the account’s username: the admin schedule page, the dashboard schedule module, and bulk score entry. The visitor site redirects that request to the organization’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.

We 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. We verified it live on the page the teammate had reported.
  • Second follow-up: This one was the fix for the cause, where the previous one had only made the failure quieter. When the direct schedule call fails for an account inside an organization, the callers retry through the organization’s site, passing the account as div=<account>. The endpoint has always supported that, and it is the same path the organization’s public schedule filter uses. On those sites the calendar filter rendered for the first time, and the dashboard Upcoming Schedule module started showing the account’s schedule.

The shadow handlers

An overnight pass to remove local AjaxError overrides turned up four admin pages, not the three we had named, each with its own copy of the error box. They took over from the shared handler, so nobody saw the session-timeout message there. The Members page’s was the worst. It wrote into a box most Members pages don’t have, so it showed nothing at all. We removed the copies from Colors, Locations and Members, and gave the photo upload popup the message directly since it runs independently. We 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, 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.

Takeaway

Check whether a failed save tells the user anything, and put that message in one shared handler rather than page by page. Expect the first release of a working error message to expose background failures that were already happening, and plan time to work through them.