Software Engineering

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.

Jay McBride

Jay McBride

Software Engineer

5 min read

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:

ruby
begin
  process_payment(order)
rescue => e
  Rails.logger.error("Payment failed")
  render json: { error: "Something went wrong" }, status: 500
end

Three 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:

ruby
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
end

Longer, 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.

Share

Pass it to someone who needs it

About the Author
Jay McBride

Jay McBride

Software engineer with 20 years building production systems and mentoring developers. I write about the tradeoffs nobody mentions, the decisions that break at scale, and what actually matters when you ship. If you've already seen the AI summaries, you're in the right place.

Based on 20 years building production systems and mentoring developers.

Support my work on Buy Me a Coffee
Keep Reading

More Articles

/ 4 min read

Configuration Is Where Systems Quietly Get Complicated

Nobody plans a configuration problem. It accumulates one environment variable at a time until deployments depend on knowledge that lives in someone's head instead of in the repository.

Read article
/ 3 min read

Runbooks Are Boring Until the Incident Belongs to You

Teams postpone runbooks because documentation feels secondary during calm periods. Then an incident lands on the wrong person at the wrong time and institutional memory turns out to be a very weak system.

Read article