Emmanuel EbriEmmanuel Ebri
Back to Blog
UXError HandlingFintech

Your Error Messages Are a Product Surface

Most software fails badly because nobody owns failure

5 min read

Your Error Messages Are a Product Surface cover

My mobile sign up once failed with this: the request took longer than expected 0:0:20.000000.

Nothing was broken. My hosting spins containers down when they go idle, and the first request after a quiet period took twenty four seconds to boot and respond. The client had a twenty second timeout, so it gave up four seconds early and reported a failure that had not happened.

Two things went wrong there, and only one of them was the timeout. The other is that the message told the user about my HTTP client's internal state, in a duration format nobody outside the codebase has ever seen, and said nothing about what they should do.

That second failure is everywhere in software, and it survives because no one is responsible for it. Designers specify the happy path. Engineers implement the logic. The error state falls between them and ends up being whatever the exception happened to say.

Failure is not an edge case

The framing that causes the damage is treating errors as exceptional. In a product used on mobile data, in a market with unreliable connectivity, failure is not rare. It is a normal daily state of the application, experienced by a large share of users in any given week.

If a meaningful percentage of sessions include at least one failed request, then the failure state is not an edge case, it is a screen. It deserves the same attention as any other screen, which means it needs to answer the questions a user actually has.

There are three, and every good failure message answers all three. What happened, in the user's terms. Whose problem it is. What to do next.

Most messages answer none of them. "Something went wrong" answers none. "Error 500" answers none. "Request failed with status code 422" answers none while also leaking implementation.

The taxonomy that fixed it for me

I stopped writing error copy per catch block and started mapping errors into a small set of kinds, each with a defined shape of response.

One mapping, four kinds. Line 17 is the sentence that took me longest to learn to write.

Four kinds cover almost everything.

There is the user's input being wrong, which belongs next to the field, not in a banner, and should say what would be right rather than what was wrong. There is a legitimate business state that blocks the action, insufficient balance, expired voucher, closed cohort, which is not an error at all really, it is information, and it should come with the action that resolves it. There is the transient failure, timeout, network drop, provider unavailable, where the crucial thing is telling the user whether it is safe to retry. And there is the genuine defect, where honesty and a reference code beat any attempt to explain.

The mapping happens in one place. That matters more than the specific categories, because it means the quality of failure copy is a property of the system rather than a property of whoever wrote that particular catch block on a Friday.

The sentence that money products need

There is one line in that code that took me the longest to learn to write.

Your money has not moved.

When a payment request times out, the user does not want an apology. They want to know whether they have been charged, because their next action depends entirely on the answer. Without that sentence, a cautious person waits and checks their bank, and an impatient person taps pay again. The second group is why ambiguous failure messages produce duplicate transactions.

If you can determine the state, say it plainly. If you genuinely cannot, say that too, and give them a reference and a place to check. What you must not do is write something neutral and comfortable that leaves the question open, because the ambiguity is not resolved by the user shrugging. It is resolved by them guessing.

The failure that hides

The worst error handling I have shipped was not a bad message. It was no message.

A misconfigured CORS rule was making every API call from one origin fail. The dashboard rendered as an almost empty page, because failed fetches fell back to empty arrays throughout the app. Exactly one screen had an explicit error card, so a total outage presented as a product that looked like it had no content rather than one that was broken.

Empty and failed rendering identically is a lie the interface tells, and it does not only mislead the user. It misled me, because if a failed fetch produces an empty array and no log line, then my monitoring cannot distinguish the two either.

Every list in a product needs three visually distinct states. Loading, failed, and genuinely empty. Collapsing the last two feels tidy and costs you the ability to notice outages.

What to do this week

You do not need a redesign. Pick the three most common failures in your product, which you can get from your error tracker in about five minutes, and rewrite those three messages so they answer the three questions.

Then find every place a failed fetch renders as empty and make it render as failed, with a retry. While you are there, log it, because that instrumentation is what would have told me about the CORS outage on the day it started rather than whenever a user thought to complain.

Then write one rule down for the team: no error message ships that a person outside the codebase cannot act on.

Users forgive software that breaks. Everything breaks. What they do not forgive is software that breaks and then refuses to tell them what it did with their money.

I build EdTech and fintech products at E-Bringgs Technologies. The twenty four second cold start is real, and so is the four second gap that started this.