İçeriğe geç
wedevit

October 13, 2026 · 9 min read · software

İlhan Buğra Aslan

All the user sees is “Something went wrong”: how to design error handling


Error handling is not one job. It is three decisions made in different places: what the user sees on screen, what the system records for later, and what the application does next. Most teams only build the first one, and they build it as a single generic sentence. The pattern that follows is familiar. The user has no idea what to do, support gets a screenshot with nothing in it, and the developer cannot reproduce the failure so the ticket closes as "works for me." Fixing this rarely means new infrastructure. It means drawing a few boundaries that were never drawn.

The cost of a generic error shows up in the support queue

The price of an error screen is not measured by how many people see it. It is measured by what happens next. The user retries. Then retries again, and this time the payment goes through twice. Then calls support, where the only available evidence is "it said something went wrong."

Part of this is usability. Part of it is not. When OWASP refreshed its Top 10 in 2025, it added A10 Mishandling of Exceptional Conditions as a brand new category and mapped 24 distinct weakness types underneath it. The definition is worth reading slowly: software fails to prevent an unusual situation, fails to detect it while it is happening, or responds badly afterwards. Looking at the full OWASP Top 10 2025 list, it is clear why this one is new. The vulnerability is rarely the failure itself. It is the reaction to it.

You cannot design one screen for three different kinds of error

Every error you will meet in production falls into one of three buckets, and each bucket deserves a different message, a different status code, a different log level and a different retry policy.

  • Errors the user can fix. A missing field, a malformed tax number, a file over the size limit. Tell them what to change and the problem is over. Most of these do not even need to be logged.
  • Transient system errors. A timeout, a lock conflict, a 503 from a downstream provider. It will probably work in two seconds. The right response here is usually not to tell the user anything, it is to retry quietly.
  • Permanent system errors. An unhandled exception, corrupt data, missing configuration. There is nothing the user can do and somebody needs to look at it.

Without that split, the codebase ends up with one catch block that answers everything the same way. A transient timeout gets presented to the user as a hard failure, and a genuine data corruption hides behind "please try again later."

Tell the user three things: what happened, what to do, what number to quote

The ninth of Jakob Nielsen's ten usability heuristics, published back in 1994, is about exactly this, and it is still broken daily. Nielsen Norman Group's error message guidelines ask for three properties: plain language, a precise statement of what went wrong, and a constructive suggestion for fixing it. "Invalid input" delivers none of them.

For form errors this is not a matter of taste, it is a conformance criterion. In WCAG 2.2, 3.3.1 Error Identification sits at Level A and requires the field in error to be identified and described in text. 3.3.3 Error Suggestion sits at Level AA and requires you to also describe the fix in text when you know it, unless saying so would undermine security. If accessibility obligations ever land on your roadmap, validation messages are among the first things an auditor opens.

The third part is missing from most products entirely: a short reference the user can read out to support.

An error screen without a correlation ID is a dead end for support

The message on screen has to stay short, and it still has to be traceable. The thing that reconciles those two requirements is a correlation ID: one value generated when the request enters the system, carried across every service, written into every log line, and shown to the user on the error screen.

You do not need to invent a header for this. W3C's Trace Context specification became a Recommendation on 23 November 2021 and defines traceparent as the standard way to propagate request identity across services. Most tracing libraries already produce it. What is usually missing is carrying that ID all the way to the interface and making it searchable in the support tool. The moment an agent can say "read me the reference code," average resolution time drops, because the guessing round in the middle disappears.

Your error format is part of your API contract

Fixing the screen and leaving the API alone is half a job. If an integrator cannot distinguish error cases programmatically, their only option is to pattern-match on your message text, which breaks the first time you reword it.

The standard already exists. RFC 9457 Problem Details for HTTP APIs was published in July 2023 and obsoletes RFC 7807. Using the application/problem+json media type, it defines five members: type (a URI identifying the problem type), title (a short summary that should not change between occurrences), status, detail (an explanation specific to this occurrence) and instance. Two of its notes are directly useful. First, the detail string "ought to focus on helping the client correct the problem, rather than giving debugging information." Second, clients must ignore extension members they do not recognise, which means you can carry a list of field-level validation failures in your own extension and keep extending it later. Treat that format as part of your API versioning policy rather than something you can quietly reshape.

Pick status codes by meaning, not by mood

RFC 9110 draws the line clearly: the 4xx class means the request was bad or cannot be fulfilled, the 5xx class means the server knows it erred or cannot perform the method. 422 Unprocessable Content sits between them, for when the content type is understood and the syntax is correct but the instructions inside cannot be processed. That is the natural home for business rule validation.

The more common mistake happens earlier: returning HTTP 200 with {"success": false} in the body. That one choice breaks three things at once. Monitoring cannot see your error rate, which makes any SLO and error budget arithmetic meaningless. Proxies and caches may treat the response as a successful one and store it. Client libraries never trigger their built-in retry logic, because as far as HTTP is concerned nothing went wrong.

Leaking a stack trace is a vulnerability, not an untidy config

A verbose error page left on in production hands an attacker the reconnaissance they would otherwise have to work for. CWE-209 treats this as a weakness in its own right, and the examples it gives are the ones you will recognise: full filesystem paths, SQL errors that reveal query structure, credentials sitting inside a connection string. Its guidance fits in a sentence. Error messages should contain only the minimal detail useful to their intended audience, and everything else belongs in the log.

The practical checklist is short. Verbose error output off in production, framework debug mode off, 404 and 500 pages customised, exception class names not leaking into API responses. Then check the reverse direction too. Logging an exception object wholesale often drags the request body with it, which is how passwords and card numbers end up in log storage. Once written, that data lives in your log pipeline for the whole retention period.

When something breaks, does your system fail open or closed?

One of the weaknesses OWASP lists under A10 is CWE-636 Not Failing Securely. The question it poses is blunt. When the authorization service times out, does your application allow the request or deny it? When a licence check does not answer, does the premium feature stay unlocked? When the payment provider goes quiet, does the order count as paid?

One of OWASP's own example scenarios for this category is exactly that failure mode: an attacker interrupts a multi-step financial transaction and the rollback never runs, leaving corrupted state behind. The rule they give is to roll back every part of a transaction you were partway through. In payment integrations this translates into something concrete. Keep a third state called "unknown," close it through reconciliation, and never interpret a timeout as either success or failure.

Retries make outages worse when they are built carelessly

Retrying only makes sense for transient failures. Retrying a permanent 400 just adds load. A correct retry has three parts: exponential backoff, randomness, and a hard ceiling.

The randomness is the part teams skip, and it is the expensive one to skip. Marc Brooker's Exponential Backoff And Jitter, published on the AWS Architecture blog in 2015, makes the point directly: backoff alone is not enough, because every client that failed at the same moment also retries at the same moment and knocks over the service that was already struggling. Spreading those retries with random delay breaks up the wave. On the server side, a Retry-After header does similar work by telling callers when to come back. Write operations need one more thing, a unique idempotency key, otherwise retrying a request that actually succeeded but lost its response creates a second record. Anything that does not need to block the user belongs in a background queue where retry policy lives in one place, and the circuit breaker pattern from surviving third-party outages completes the picture.

The error path is code too, and it is usually untested

OWASP's first example scenario for A10 is resource exhaustion: each new exception leaves resources locked or otherwise unavailable until there are none left. The cause is nearly always the same, an exception thrown before the line that closes the connection, the file handle or the lock.

Do not let those branches run for the first time in production. Write tests that make a dependency time out, return a 500, and drop the connection halfway through a response. During load testing, deliberately slow a downstream service and watch what the connection pool does. Put at least one failure path into your acceptance test script: what does the user see when a card is declined, and does an account still get created when the email service is down during signup?

A first step that fits in one working day

Take five support tickets from the last thirty days where an error screen was involved, and answer three questions for each. What did the user see, could you find that exact request in the logs, and how long did it take you? Everything you could not find is a correlation ID problem, and that is usually a day of work. Then confirm verbose error output is off in production, move the error responses of your three busiest API endpoints onto one format (ideally application/problem+json), and list which of your outbound calls actually use backoff with jitter.

Those four checks fit into a single day and tend to expose whatever is going to drive next quarter's support load. If you would like us to review how your application behaves when things break, bring a handful of real support tickets along. That conversation moves much faster than an abstract architecture discussion.


Need help with this topic?

get in touch →← all posts