Loud Errors, Quiet Errors
The Art of Failing Gracefully
Most of us learned error handling as a binary system: an operation either succeeds, or it fails. And if it fails, somebody should hear about it.
This way of thinking works well for developers, since software is built on invariants.
An invariant either holds, or it doesn’t.
A null where there shouldn’t be one, a timeout, a 500 from the backend… all of them are violations of expectations that deserve our attention.
But users don’t experience our software as a set of invariants.
They experience it as a flow.
And somewhere between the catch block and the screen, we tend to forget that these are two very different things.
Be aware that I’m not a UX designer or even do React/TypeScript for a living! This article is written from my perspective as a developer who has written a lot of
catchblocks that ended in a modal dialog, and came to regret most of them. The chosen tech stack in the article is for illustration purposes.
Table of Contents
Strict Code, Forgiving Interfaces
For us developers, errors are exceptional states, hence the name “exception”. Something we didn’t expect happened, and the happy path is gone, so our code needs to react.
For users, however, software is a tool, not a provable system. For them, an error is an interruption, and interruptions aren’t binary. They can be mild or severe, and fall anywhere in between. A missing thumbnail and a failed payment are both “errors” in a sense, but nobody would argue they feel the same or should have the same impact.
The common mistake we make is mapping technical severity directly to UI loudness. We treat every exception the same, regardless of what actually failed.
Let’s look at a profile page that loads a user, their avatar, and their recent orders:
async function loadProfile(userId: string) {
try {
const user = await api.getUser(userId);
const avatar = await api.getAvatar(user.avatarId);
const orders = await api.getOrders(userId);
render({ user, avatar, orders });
} catch (err) {
showErrorModal("Something went wrong. Please try again.");
}
}At first glance, this code seems reasonable. Every failure is caught, nothing crashes, and the user is informed. It’s the kind of code that sails through code review.
But there’s a problem: every failure gets the same treatment.
If the image CDN has a bad day, the avatar request fails. The user came to check on an order, and the order data was right there, perfectly fine. But we blocked the whole page with an error modal because a decorative picture was missing.
A failed image load turned into a dead end for the user.
Nothing in the code is wrong, technically speaking. The problem is that the code only knows about technical failure. It doesn’t know what each failure means for the person in front of the screen.
So what would a more proportional response look like?
async function loadProfile(userId: string) {
// LOUD: without the user, there's nothing to show,
// so let it propagate to the page-level handler
const user = await api.getUser(userId);
const [avatar, orders] = await Promise.allSettled([
api.getAvatar(user.avatarId),
api.getOrders(userId),
]);
// QUIET: report it, fall back to initials, nobody needs to know
if (avatar.status === "rejected") {
report(avatar.reason, { component: "avatar" });
}
// SOFT: the orders are why the user is here, so report it,
// and tell them inside the orders section
if (orders.status === "rejected") {
report(orders.reason, { component: "orders" });
}
render({
user,
avatar: avatar.status === "fulfilled" ? avatar.value : initialsFor(user),
orders: orders.status === "fulfilled" ? orders.value : OrdersUnavailable,
});
}The same three requests, but at three different error volumes. And as a bonus, the avatar and orders now load in parallel instead of one after another.
Error Loudness Is a UX Decision, Not a Technical One
Every error has two dimensions:
Technical impact:
How bad is it for the system? E.g., the state is corrupted, a dependency is down, an invariant is broken.User impact:
How bad is it for the person using the software? Can they still do what they came to do?
The truth we overlook in many cases is that these two are often not correlated at all.
A database failover might be a major incident for the backend team, but if the UI retries quietly and the user waits a few hundred milliseconds longer, they won’t even notice. Meanwhile, a perfectly harmless validation hiccup that pops up a modal on every keystroke makes the software feel like it’s falling apart.
Users judge our software by the frequency of interruptions, not by the quality of our stack traces. That’s why we can’t decide how loud an error should be just by its exception type.
The code knows what failed. Only we, as designers of the experience, know what each failure means for the user.
A Taxonomy of Error Loudness
To make “volume” something we can reason about, let’s give it three levels:
LEVEL BLOCKS FLOW? THE USER SEES IT FEELS LIKE
----- ------------ ----------------------- ----------------------
Loud yes modal, full-page error a red traffic light
Soft no toast, inline, banner a tap on the shoulder
Quiet no nothing, or a fallback a hiccup, if anythingLoud Errors: The Red Traffic Light
Loud errors stop the flow completely, as the user has to acknowledge them before doing anything else:
- Modal dialogs
- Full-page error screens
- Blocking alerts
The message is unambiguous: you cannot proceed.
Loud errors are expensive and should be rare.
They should be intentional, meaning someone actually decided this failure deserves to stop everything, instead of it being the default path of a generic catch.
Soft Errors: The Tap on the Shoulder
Soft errors are still visible, but they don’t take control away from the user:
- Toasts and snackbars
- Inline validation next to the field in question
- Status banners, like “Working offline, changes will sync later”
The message here is: you should know about this, but you’re still in charge.
This is where most mistakes happen, in both directions. Be too timid, and the user never notices their changes weren’t saved. But if we’re too aggressive, we end up interrupting users over the small things, just in a less obvious way.
Quiet Errors: The Blip
Quiet errors don’t interrupt at all:
- Automatic retries
- Decorative content that simply doesn’t show up
- A skeleton loader that resolves into a sensible empty state or fallback
Be aware that quiet doesn’t mean ignored.
A quiet error is an error handled without any ceremony, not an error ignored in an empty catch block.
We’ll come back to that distinction at the end, because it’s the part that makes or breaks the whole approach.
The Goodwill Battery
Every user starts a session with a certain amount of patience. Think of it as a goodwill battery.
Every interruption drains it a little. A modal for something minor drains it even faster. Once it’s empty, the user doesn’t think “that was an unfortunate edge case”, they think “this app/site is broken”.
Loudness is how we spend that charge, so we should spend it only where it actually matters.
Crying Wolf
There’s a second, less obvious cost to unnecessary loud errors: they train users.
Show enough pointless modals, and people learn to dismiss them reflexively without giving them even a glance. Click. Gone. Back to work.
And then comes the one modal that actually mattered…
This is called alarm fatigue, and it’s a well-known problem in places like hospitals and cockpits, not just in the software world. Security warnings suffer the most from it. Every certificate warning we’ve ever trained users to click through makes the next real one less effective.
Perceived vs. Actual Stability
Let’s compare two apps that are equally unreliable under the hood:
App A App B
Failures per week: 50 50
Handled quietly: 46 (retry/fallback) 0
Soft (toast/inline): 4 0
Loud (modal): 0 50
Interruptions per week: 4 (all dismissable) 50 (all blocking)Same backend, same failure rate. But App A feels solid (for the user), whereas App B feels broken.
Stability is a perception problem as much as it is a reliability problem.
The worst part is that most monitoring tools don’t show this cost at all.
Our error rate dashboards show the same 5xx count for both apps.
No metric tells us that App B’s users closed the tab in frustration, while App A’s users might never have noticed anything.
Not Everyone Hears the Same Volume
Even though we decide how loud an error is, the user is the one who actually experiences it. And we need to be aware that not everyone perceives it the same way.
- Toasts with no
role="status"oraria-liveare quiet errors for a screen-reader user, no matter how visible they are on screen. - Modals that don’t take focus first are loud errors that never arrive, or worse, a trap.
- Sections that “gracefully” degrade away are exactly as broken as a full-page error, if you were navigating by headings.
The reverse holds too: an assertive announcement on every retry rebuilds the alarm fatigue from Crying Wolf, for people with even less patience to spare.
The volume knob has to be set differently for each user type.
Turning Down the Volume
Knowing an error should be quieter is one thing. Building the UI so it actually can be quieter is another.
Here are two patterns that do most of the heavy lifting.
Optimistic UI with Gentle Rollback
The classic approach to any user action is to wait for the server, then show the result:
// BEFORE: the user waits for the network, then gets yelled at
async function toggleFavorite(item: Item) {
showSpinner();
try {
await api.setFavorite(item.id, !item.favorite);
item.favorite = !item.favorite;
render(item);
} catch (err) {
showErrorModal("Could not update favorite.");
} finally {
hideSpinner();
}
}Now, every time someone taps the heart icon, they see a spinner, and any network issue pops up a modal. The error is right in the middle of the critical interaction loop, meaning the tight cycle of action and feedback the user is in.
An optimistic UI flips the order:
// AFTER: the UI moves first, the network catches up
async function toggleFavorite(item: Item) {
const previous = item.favorite;
// STEP 1: update immediately, assume success
item.favorite = !previous;
render(item);
try {
// STEP 2: sync in the background
await api.setFavorite(item.id, item.favorite);
} catch (err) {
// STEP 3: roll back, and tap on the shoulder
item.favorite = previous;
render(item);
toast("Couldn't save your favorite, please try again.");
report(err, { action: "toggleFavorite" });
}
}The user gets instant feedback, and if something (rarely) fails, it becomes a soft error outside the main interaction.
There’s a caveat, of course.
Optimistic updates are a bet that the operation almost always succeeds. Usually, that’s true. But if it fails often, the UI flickers back and forth, which is arguably worse than a spinner. Furthermore, rapidly repeated toggles can race each other. With responses arriving out of order, we need to either serialize requests per item or track which response is the latest one.
Still, an optimistic UI works well for simple, frequent, and easily reversible actions like favorites, likes, reordering a list, or marking something as read. But it’s not the right approach for things where undoing isn’t acceptable, like payments or actions that can’t be reversed.
Retries with Backoff
Many failures aren’t really hard failures.
A train goes into a tunnel, a Wi-Fi network hands over to another access point, a load balancer drops a connection. These are environmental, not user errors, and most of them resolve themselves within a second or two. If we show these errors to the user, we’re giving them a problem they can’t solve themselves.
Instead, a small retry helper with exponential backoff can absorb most of the impact:
async function withRetry<T>(
operation: () => Promise<T>,
maxAttempts = 3,
baseDelayMs = 300,
): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await operation();
} catch (err) {
// GIVE UP: out of attempts, or retrying won't help
if (attempt >= maxAttempts || !isTransient(err)) {
throw err;
}
// BACKOFF: 300ms, 600ms, ... (doubling, plus random jitter)
const delay = baseDelayMs * 2 ** (attempt - 1);
await sleep(delay + Math.random() * delay);
}
}
}Only after the third strike does the error surface, and even then, usually as a soft error, not a loud one.
Not every error deserves a second chance, though. If the server rejected our request because the data is invalid, asking again won’t change its mind. We’d just make the user wait three times as long for the same answer.
That’s why the isTransient check is doing more work than it looks like.
It draws the line between errors caused by the environment and errors caused by the request itself.
Network errors, 408, 429, 502, 503, and 504 are worth retrying.
If the response carries a Retry-After header, that wins over our backoff.
A 429 that asked for 60 seconds isn’t improved by asking again after 300ms.
A 400 or 422 will fail the exact same way no matter how often we ask, and a 401 needs a login, not patience.
This simple approach already turns many would-be loud errors into soft ones, or even quiet ones. But nothing comes for free:
Latency:
Retries hide errors by converting them into waiting time. Let’s get our calculators out:textAttempt 1 fails: + 300ms to 600ms Attempt 2 fails: + 600ms to 1200ms Attempt 3 fails: surface the error Worst case added: ~1.8s before the user hears anythingThat’s acceptable for loading a list, but definitely not for a button the user is staring at without any feedback.
Idempotency:
Retrying aGETis harmless. Retrying aPOST /orderswhose response got lost, but whose request did arrive, creates a second order. That’s why non-idempotent operations need an idempotency key, like anIdempotency-Keyheader the server uses to deduplicate (currently an IETF draft), before they’re safe to retry.Retry storms:
If the backend is down for everyone, each client retries at the same time, turning an outage into a self-inflicted DDoS. That’s what the jitter is for: it spreads the retries out, so they don’t arrive in synchronized waves.
So retries aren’t a magic “make errors go away” button. They work best for reads and idempotent writes, with a small attempt budget, and a UI that shows something is happening while we wait. Used that way, most connectivity issues never reach the user at all. And the ones that do have actually earned their toast.
Containing the Blast Radius
The two patterns above are about how often a failure reaches the user. This one is about how much of the screen it takes down with it.
Imagine an order history page with recommendations:
+------------------------------+
| Header |
+---------------+--------------+
| Orders | Recommended |
| #1042 ... | Article #1 |
| #1041 ... | Article #2 |
| #1040 ... | ... |
+---------------+--------------+When one part of a page fails, let’s say the recommendation widget, the naive approach lets the failure propagate upward until something catches it. That’s usually a handler at the very top that replaces everything:
+------------------------------+
| |
| |
| Something went wrong. |
| |
| [ Reload ] |
| |
+------------------------------+The recommendations widget failed, not the whole page.
From a user’s point of view, that’s the most unsatisfactory result, since all information is lost. In some cases, we can’t handle it differently, but we can isolate errors and still provide the user with a best-effort result.
In React, this is what error boundaries are for. They only catch errors thrown during render, though, not the async failures from the examples above:
<Page>
<Header />
<OrderHistory />
{/* ISOLATED: a failure here must not take down the page,
but it still must be reported */}
<ErrorBoundary
fallback={null}
onError={(err) => report(err, { component: "recommendations" })}>
<Recommendations />
</ErrorBoundary>
</Page>Now, the user is still confronted with a partially “broken” page, but it’s still a better UX than before:
+------------------------------+
| Header |
+---------------+--------------+
| Orders | |
| #1042 ... | |
| #1041 ... | (hidden) | <== degraded recommendation widget
| #1040 ... | |
+---------------+--------------+This is not about using a specific React component, but the underlying idea. Decide upfront which parts of a screen are allowed to fail independently, and give each of them its own fallback. That’s an architecture decision, and it’s best made while designing the screen, not while debugging it.
We can take this one step further with an actual circuit breaker, borrowed from backend resilience patterns. If the recommendations endpoint failed three times in a row, say, stop calling it for the next 60 seconds, and render the fallback right away. No point in making every page load wait for a timeout we already know is coming. Too low a threshold, and a brief blip keeps a healthy endpoint switched off for the full cooldown. Too high, and it never trips at all.
The cost here is subtle and often only reveals itself if something becomes an ongoing problem: isolation hides bugs.
If we wrap everything in its own boundary with an empty fallback, a completely broken page simply looks like an empty page, and nobody files a ticket for “the page seems a bit sparse today”. Every boundary needs a deliberate fallback, and every boundary needs to report what it caught.
When Failing Loudly Is the Right Choice
After all this talk about turning the volume down, it’s easy to think that loud errors are a design failure to begin with.
They aren’t.
Loud errors are justified when graceful degradation is impossible, or even dangerous. There are three situations where that’s the case.
Irreversible or Destructive Actions
Two types of actions always need special consideration: deleting data and anything involving money.
If the delete request fails, a quiet retry and a toast saying “Hmm, that didn’t work” doesn’t cut it. The user has to know, with certainty, whether the thing they wanted to happen did or did not happen.
Money is even trickier.
If a payment request times out, we don’t actually know whether the charge went through or not. Retrying quietly risks charging the user twice. Staying quiet risks the user paying again manually, because they assume it failed.
Neither is a hiccup we can paper over with a fallback.
That’s also why destructive actions often get friction before anything fails, like a confirmation dialog before deleting a project. It’s a loud interaction by design, because undoing it is expensive, or outright impossible.
Here, friction is a feature.
Security and Permission Boundaries
Imagine a session expiring while the user is filling out a long form. Or an account trying an action it simply lacks the permission for.
These failures aren’t transient, so retrying won’t help. And there’s no sensible fallback either, as we often can’t render a “degraded” version of something the user isn’t allowed to do.
If the system can’t continue safely or legally, it must stop, and it must say so, and why. Degrading seamlessly around an authorization failure just means the user keeps working, believing their actions have an effect, until they find out much later they didn’t.
That doesn’t mean the loud error has to be careless, though. For the expired session, a good one keeps the form input around and brings the user right back to it after logging in again.
The Core Utility Is Blocked
A navigation app without location access can still show a map. What it can’t do is navigate.
A small “Location unavailable” banner in the corner would technically count as graceful degradation. However, the user asked for directions, and a map that doesn’t follow them is useless at best, and dangerous at worst while they’re driving.
The difference from the recommendations widget is what the failure means to the user. Recommendations are a side dish, whereas navigation is the whole meal. When the main purpose can’t be fulfilled, a loud error is the only honest answer, ideally with a way forward, like a button that opens location settings.
If the app’s primary purpose is impossible, staying quiet stops being considerate. In that case, it is deception.
Loud Errors Must Offer a Way Forward
Even when an error deserves to be loud, it doesn’t get a free pass on being useful.
Compare these two:
BAD: Something went wrong.
GOOD: Payment declined. Your card expired in 09/26. [ Update card ]The first one stops the user and gives them nothing. The second one stops the user and immediately tells them how to continue. It still might fail down the line, yet the user isn’t stuck.
A loud error without agency is just punishment for something that could be out of the user’s control.
A Heuristic for the Volume Knob
All of the above condenses into a handful of questions, asked in order, and the first match wins:
QUESTION VOLUME
--------------------------------------------- ------------------------
1. Is data at risk, or is it irreversible? LOUD
2. Is the user's primary goal blocked? LOUD
3. Can the user fix it right now? SOFT
4. Is it transient? QUIET (retry), then SOFT
5. Is it the primary task, partly? SOFT (say what didn't make it)
6. Is it secondary or decorative? QUIET (but report it!)The fifth question is the easiest one to skip. An export that returns 397 of 400 rows matches none of the four above it: nothing is at risk, the goal isn’t blocked, the user can’t fix it, and it won’t resolve on its own. It looks like a complete export until someone opens the file.
If nothing changes for the user, nothing should interrupt them.
This is not a perfect decision tree, and there will be cases where reasonable people disagree about the chosen volume.
But it’s a lot better than the implicit heuristic most codebases actually follow, which is “whatever the catch block closest to the top does”.
The Strict Machine and the Partner
At the core, two ideas shape what software should be.
The strict machine halts on any inconsistency, as it optimizes for correctness and treats every input as untrusted.
For example, that’s exactly what we want from a compiler.
When tsc yells at us for passing a string | undefined where a string is expected, it’s doing its job, and we’d be furious if it simply guessed and carried on.
Rust’s borrow checker takes this to the extreme, rejecting whole classes of bugs at compile time, even if that means refusing some programs that would’ve worked just fine.
But it’s a terrible model for a user interface.
The partner, however, optimizes for the user completing their task. It recovers when it can, makes a reasonable choice when something minor goes wrong, and only stops the user when it genuinely needs to.
Good UI behaves like a collaborator, not a referee. The best systems are strict internally, validating everything and failing fast at the boundaries, and forgiving externally.
Strict code, but forgiving interfaces.
Silent to the User, Loud to the Developer
Here’s the part that can make everything above either a good idea or a dangerous one.
Turning down the volume for the user increases our responsibility to hear the error ourselves. A quiet error that’s never reported is just a bug with good PR.
Every fallback, swallowed retry, and error boundary needs to report what it caught.
That’s why the earlier examples always called report(...), even when the user didn’t see anything.
Under the hood, we can use navigator.sendBeacon(), which the browser sends even if the page is closing:
function report(err: unknown, context: Record<string, string> = {}) {
const payload = JSON.stringify({
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
url: location.href,
...context,
});
// BEACON: fire-and-forget, survives page unloads, blocks nothing
// BLOB: a bare string would go out as text/plain, not JSON
const queued = navigator.sendBeacon(
"/telemetry/errors",
new Blob([payload], { type: "application/json" }),
);
// QUEUE FULL: don't let the reporter itself fail silently
if (!queued) {
console.warn("telemetry dropped", err);
}
}
// SAFETY NET: catch whatever slipped past all our handlers
window.addEventListener("error", (event) => report(event.error));
window.addEventListener("unhandledrejection", (event) => report(event.reason));Logging alone isn’t enough, though.
Quiet errors need to be measured, too, ideally tagged by component and by the loudness level they were handled at. A sudden spike in quiet avatar fallbacks is an early warning that the CDN might be in trouble, long before anyone opens a support ticket. That’s the real risk of quiet failures: they’re invisible by design. Without telemetry, we’ve built a system that hides its own problems from the very people who could fix them.
Silent without observability isn’t graceful design. It’s just ignoring reality.
Proportional Response
Error handling is usually taught as a purely technical concern:
- Catch the exception
- Handle it or log it
- Tell the user
And for a long time, that’s how I wrote my code, and still often do for proofs-of-concept or throwaway things. But for real user-facing interfaces, we need to do better.
However, the answer isn’t to swing to the other extreme and hide everything with layers of fallbacks and silence. Some failures absolutely deserve to be as loud as possible and stop the user right in their tracks! A UI that’s too polite to admit it can’t do its job is just as bad as one that’s too loud in the first place.
What actually goes wrong is the way we decide how loud an exception should be. We often let an exception’s type decide, instead of thinking about what it means for the person in front of the screen.
But resilient UI isn’t about preventing errors. That’s impossible. It’s about responding in a way that fits the situation:
Minimize interruptions:
Retry first, then degrade to a fallback, and only then involve the user.Maximize observability:
Everything the user doesn’t see, we need to see even more clearly.Respect the user’s momentum:
When we do interrupt the user, make it count, and always offer a way forward.
Next time you write a catch block, ask yourself not just what failed, but how loud the error should be before showing a modal.
Resources
- Timeouts, retries, and backoff with jitter (Amazon Builders’ Library): timeouts, capped backoff, and why jitter isn’t optional.
- Addressing Cascading Failures (Google SRE book): how retries at every layer multiply into an outage.
- Release It! (2nd ed.) by Michael Nygard: circuit breakers, bulkheads, and the rest of the stability patterns, from the backend side.
- Designing robust and predictable APIs with idempotency (Stripe): idempotency keys, from people who can’t afford double charges.
- Error-Messages Scoring Rubric (Nielsen Norman Group): twelve guidelines across visibility, communication, and efficiency.
- Alice in Warningland (USENIX Security 2013): alarm fatigue, measured on 25 million browser warnings.
- Understanding SC 4.1.3: Status Messages (W3C): why a toast needs a role to exist for everyone.