The worst bug reports are the ones that cannot exist. A user does something, the app says it worked, and it did not — so there is nothing to report except a feeling that the software is unreliable, which is not a bug report, which is why you never hear it.
I found four of these in the same codebase within a fortnight. Every one of them was a catch block doing what it had been told.
One: the success message with nothing behind it
Saving a custom template wrote to browser storage, notified the interface that templates had changed, and returned. The write was wrapped in a try/catch that swallowed failures, and the change notification fired regardless.
So when a write failed: the sidebar refreshed, found nothing new, drew the same list it already had — and the calling code, having received no error, showed a cheerful "Saved".
The user got a success message, no template, and nothing anywhere explaining the discrepancy. If they looked again later and their template was missing, the most reasonable conclusion available to them is that the app loses things. That belief, once formed, is not recoverable by anything except never doing it again.
It throws now, and the callers act on it: saving shows a storage error instead of a success toast, and deleting reports the failure rather than silently keeping the entry it claimed to remove.
The read-back is as important as the catch
There is a second half to that fix, and it is the part I would most like people to take away.
Adding the throw handles the case where the write raises an error. It does not handle the case where the write returns quietly and the value is not there afterwards — a storage quota that fails softly, a policy that silently discards, a value written to the wrong place.
"It did not throw" is not the same claim as "the value is there", and only one of those is what the user is being told. So the write is followed by a read-back, and the read-back is what the success message is actually based on.
That distinction generalises to almost every persistence path in a program: the API's error channel tells you about the failures its author anticipated. Reading the value back tells you about the state of the world.
Two: the paste that discarded every clue it had
The clipboard's paste keystroke runs a small system script. Everything that can go wrong with it — Accessibility permission revoked, the system event refused, the service not answering — exits non-zero and prints a sentence to standard error explaining exactly which one.
The code spawned that process and dropped the handle. Every one of those sentences went nowhere.
Consider the combination. A paste that silently does nothing is the worst failure this feature has, from a user's point of view: they pressed the key, the thing they chose did not arrive, and the app looks fine. And it was simultaneously the only failure that left nothing behind to debug from. The most confusing symptom had the least evidence, by construction.
It waits on the process now and keeps what it says. That also reaps the child, which had been leaving a zombie process behind on every single paste — an unrelated bug, found only because somebody finally looked at the return value.
Three: the loop that stopped for the session
The clipboard poll reschedules itself after each tick, on the success path only.
Nothing awaits that tick, so a rejection had nowhere to go. It ended the loop outright. Clipboard history simply stopped recording for the rest of the session — no message, no indicator, no way for the user to know that the feature they were relying on had quietly retired at 11am.
The asymmetry here is brutal and easy to miss when writing the code. A capture that fails costs one clip. A capture that throws cost the feature. Same line, two orders of magnitude apart, and the difference is entirely in whether the scheduling happens on the success path or unconditionally.
Any self-rescheduling loop deserves that specific review: does the next tick get scheduled even when this one fails? For a poll, a retry queue, a watcher, a sync — that one question separates a transient error from a permanent one.
Four: the version skew nobody could have seen
A shipped build carried a frontend that called a command its own backend did not export. The call rejected, a helper named "invoke quietly" ate the rejection, and starting a focus session did nothing at all.
No error. No log. The user pressed the button and the app declined to have an opinion about it.
The fix is the rule I now apply to every quiet-by-default wrapper: failing to hide can stay quiet; failing to show must speak.
If hiding an overlay fails, there is nothing useful to say and nothing for the user to do. If showing it fails, the user asked for something and did not get it, and they need to know — including the useful part, which is that their timer is still running. Silence there is not restraint, it is withholding.
Most "quiet" helpers are written for the first case and then used for both, because the name does not distinguish them.
The honest ending
I could not reproduce the template failure. In a development build the whole save-notify-render path works — a probe template appears immediately — and the stored value reads as an empty array in the real app, but the app was running at the time and the browser engine buffers that storage, so the value on disk may simply have been stale.
Which means I fixed something I had not proven was broken.
I think that is legitimate here, and worth defending, because of what the change actually does: it makes the failure visible either way. If storage was never the problem, the code is now correct and the user sees nothing different. If it was, the next occurrence produces an error message and a report I can act on, instead of a person quietly deciding the app loses things.
When you cannot reproduce a fault, converting an invisible failure into a loud one is not a workaround. Quite often it is the only move available, and it is the one that turns an unfalsifiable complaint into a bug you can eventually fix.
The three questions
For any catch block, or any ignored return value:
- Does the user believe something happened? If your code is about to report success, the catch has to be able to prevent that.
- Can the user do anything about it? Permission revoked, disk full, wrong folder — those are actionable, and hiding them takes the action away.
- What does this failure cost if it repeats? One item, or the feature for the rest of the session?
The four bugs above answer badly to all three, and they were all written by someone — me — being tidy. A swallowed error looks like a considerate piece of code. It reads as we do not want to bother the user with this, which is a sentence about the developer's comfort dressed up as a sentence about the user's.
More on the same theme from the outside in: supporting an app that does not know who you are, where the absence of telemetry makes every one of these that much more expensive.