Your Error Messages Are Part of the Product
Teams treat error text as leftover work handled at the end of a ticket. Then support volume climbs, incident timelines stretch, and it turns out the system was communicating badly the whole time.
Why error messages deserve design attention, and how vague failure text quietly raises support cost, slows incident response, and hides real problems from the people who could fix them.
Introduction
Almost every team has an error message that says something like “Something went wrong.”
It ships because the happy path was the actual work, the deadline was real, and nobody wanted to argue about copy in a pull request.
Then it survives for three years, generates support tickets nobody can reproduce, and shows up in an incident where the on-call engineer has no idea which of six subsystems failed.
This article is for teams who think of error text as polish rather than as system behavior. It is behavior. It just fails quietly enough that the cost never lands on one person’s plate.
The Core Judgment: An Error Message Is a Routing Decision
Every failure message answers one question: who should act next, and what should they do?
A good error routes.
It tells the user whether to retry, wait, fix their input, or contact someone. It tells the engineer which component gave up and why. It carries enough identity — a request ID, a correlation key, a specific condition — that someone can find the rest of the story.
A bad error refuses to route. It absorbs the failure, flattens the detail, and hands back a shrug.
The system knew something specific. Then it chose not to say it.
How This Breaks in the Real World
Vague errors do not cause outages. They extend them and spread the cost sideways.
Support absorbs the first hit, because users report symptoms instead of conditions. “It didn’t work” is what you get back when the screen said “Something went wrong.”
Engineering absorbs the second hit, because reproduction becomes guesswork. Nobody knows whether the failure was a timeout, a validation rejection, a permissions problem, or a third-party outage.
Then the third hit arrives during an incident, when the log line that would have identified the failing dependency in ten seconds instead reads Error: request failed.
The information existed at the moment of failure. It was discarded on the way out.
Where the Information Gets Thrown Away
The loss almost always happens in one place, and it looks completely reasonable in review:
begin
process_payment(order)
rescue => e
Rails.logger.error("Payment failed")
render json: { error: "Something went wrong" }, status: 500
endThree separate discards in five lines. The exception class is gone. The message is gone. The backtrace is gone. And a 500 has been asserted for failures that may well have been the user’s to fix.
Consider what the same handler looks like when it keeps what it knew:
begin
process_payment(order)
rescue Payments::CardDeclined => e
Rails.logger.warn(event: "payment_declined", order_id: order.id,
code: e.decline_code, request_id: request.request_id)
render json: { error: "Your card was declined. Try a different payment method.",
code: "card_declined" }, status: 402
rescue Payments::GatewayTimeout => e
Rails.logger.error(event: "gateway_timeout", order_id: order.id,
gateway: e.gateway, request_id: request.request_id)
render json: { error: "We could not reach our payment provider. Your card was not charged.",
code: "gateway_unavailable", retryable: true }, status: 503
endLonger, obviously. But now three different audiences get what they need from the same failure. The user learns whether to act. The engineer gets a queryable event with a correlation ID. And the client gets a retryable flag instead of guessing, which is the difference between a sensible backoff and a retry storm against a provider that is already struggling.
Note the status codes too. 402 and 503 are not decoration — they tell every proxy, client, and monitor in the path something true. A blanket 500 tells them all the same lie.
The Three-Way Split Worth Making
Most failures fall into one of three buckets, and users behave completely differently depending on which one they are in:
- You can fix this — bad input, declined card, missing permission. Say what to change.
- We are broken — a bug, a failed dependency, an unhandled state. Apologize, give a reference ID, do not suggest retrying.
- Try again shortly — timeout, rate limit, transient unavailability. Say so explicitly, ideally with a hint about when.
A single generic message collapses all three, which forces the user to guess — and they will usually guess “retry,” including in the one case where retrying makes things worse.
What I Would Do Instead
I treat error paths as part of the feature, not as cleanup after it.
That means a few concrete habits:
- write the failure cases into the ticket alongside the success case
- never let a catch block swallow a cause it could have passed along
- give users the next action, not a description of the internals
- give engineers the internals, in logs, with an identifier that ties the two views together
- distinguish “you can fix this” from “we are already failing” from “try again later”
The last one matters more than it looks. Users behave very differently depending on which of those three is true, and a generic message forces them to guess wrong.
I also read production error text occasionally, on purpose, the way I would read any other part of the interface. Bad messages rarely announce themselves in review. They announce themselves in aggregate, months later, in the support queue.
Closing
Error handling gets treated as defensive work, so the message itself becomes an afterthought — a string typed quickly to satisfy a linter or a catch block.
But failure is one of the few moments when a system has the user’s full attention and the engineer’s urgent need. That is an expensive moment to waste on “Something went wrong.”
The system already knows what happened.
The only real question is whether it bothers to say so.