Nullhaus
·
6–8 minutes

An error message is an interface, not a log line

Warm amber light bleeding across a near-black charcoal surface

Almost every error string in a product was written by the person closest to the failure, in the moment they were most irritated by it, for an audience of one: themselves, later, reading logs. That is why so much of it reads like Error: unable to process request (code 500). It was never addressed to a person who was trying to get something done.

The structural problem is that three audiences are sharing one field, and they want opposite things.

  • The person using the product wants to know whether they can fix this, and what to do next.
  • Whoever answers the support ticket wants to know which failure this is, out of the forty that look identical from outside.
  • The engineer wants the stack, the inputs, and the request identifier.

One string cannot do that. Trying is what produces text that is simultaneously too technical to act on and too vague to diagnose.

Separate the identity from the sentence

The fix is not better wording. It is giving each audience its own field, which means an error is a structured object and the human sentence is one property of it.

// The machine-readable identity is a stable string, never the prose.
// Rename the prose freely; renaming the code is a breaking change.
{
  "code": "payment.card_expired",
  "status": 402,
  "retryable": false,
  "fixableBy": "user",
  "correlationId": "7QK4-2M9F",

  // shown to the person, translated, no jargon, names the object
  "userMessage": "The card ending 4142 expired in March. Add a new card to finish this order.",

  // never rendered in the UI, always in the log
  "detail": "gateway declined: expiry 03/26 < now; attempt 1; token tok_..."
}

Two things follow immediately. Support can search on payment.card_expired and get exactly one class of failure, which turns "it says something went wrong" tickets into a lookup. And the copy becomes editable by whoever writes well, without anyone touching the branch logic, because the branch is keyed on the code and not on the sentence.

The discipline to hold is that code is part of your contract. Once a client branches on it, changing it breaks them. Treat it with the same care as a field name in an API response, because that is what it is.

Classify by who can fix it, not by severity

Severity levels are for dashboards. The taxonomy that decides what the interface must do is who is capable of resolving the situation, and there are only three answers.

Who can fix itExamplesWhat the interface owes the user
The userExpired card, wrong file type, name already taken, missing permission they can requestName the specific thing, keep their input, put the corrective control next to the message
You, laterA bug, a bad migration, an unhandled stateSay plainly that it is on your side, give the correlation ID, offer the one path that still works
Nobody, right nowUpstream provider down, rate limited, a queue backed upSay it is temporary, say what happens next, and if you can, retry for them and show that you are

Mixing the second and third rows is the most common mistake, and the most expensive one, because it teaches people to retry a permanent failure forever. "Something went wrong, please try again" printed over a genuine bug is not a graceful degradation. It is a lie that costs the user their afternoon.

The correlation ID is the cheapest support feature you will ever ship

Generate an identifier per request, put it in every log line for that request, and show it to the user when something fails. That is the whole feature, and it collapses the diagnostic phase of a support conversation from a day of back and forth into one search.

Two details decide whether it actually gets used. Make it short and unambiguous, because a person may have to read it over the phone or type it from a screenshot: eight characters from an alphabet with no 0, O, 1 or I beats a full UUID by a wide margin. And make it selectable, with a copy control, rather than baked into an image or a toast that vanishes in four seconds.

If your support team's first question is "can you send a screenshot", the error interface is doing none of its job.

Specific is the goal, except where specific leaks

Almost all advice about error copy reduces to "be specific", and it is right almost everywhere. There is one family of exceptions worth naming clearly, because it is where good UX instinct and correct security behaviour genuinely conflict.

Distinguishing "this record does not exist" from "this record exists but you may not see it" tells an unauthorised caller which identifiers are real. Same for "no account with that email" during sign-in, which turns a login form into an account-existence oracle. In those cases the deliberately vaguer message is the correct one, and the specific detail belongs in the log with the correlation ID attached.

The rule that resolves it: be specific about what the user should do, and only as specific about system state as the caller is entitled to know. "That link is not valid for this account. Sign in with the account it was sent to, or request a new one" is actionable without confirming anything.

Validation is a different animal

Form validation gets grouped with error handling and then designed with the same components, which is why so many forms feel hostile. The differences that matter:

  • Position beats prominence. The message belongs at the field, not in a banner at the top summarising six problems the user now has to map back onto inputs.
  • Timing is the whole experience. Validating on every keystroke tells someone their email is invalid while they are still typing the third character. Validate on blur, and re-validate on submit.
  • Never discard input. Clearing a password field because the confirmation did not match, or dropping a long text body on a failed submit, is the single most enraging thing a form can do, and it is almost always an accident of how the state was reset.
  • State the requirement, not the violation. "Must be at least 12 characters" is useful before and after the mistake. "Invalid password" is useful to nobody.

Copy rules that survive contact with a real product

  1. Name the object. "Could not rename Q3 forecast" beats "Could not rename item", which beats "Operation failed".
  2. Say what happened, then what to do. In that order, in one or two sentences.
  3. No apologies. "Sorry, something went wrong" spends the sentence on feelings and leaves no room for information. If the failure is genuinely yours, say so in plain words and move to the next step.
  4. Never "unexpected". Every error is unexpected to the user. The word only communicates that you did not handle this case.
  5. Do not blame. "You entered an invalid date" and "That date is in the past, pick a future one" describe the same event; one of them is an accusation.
  6. Put the fix in reach. A message that says to add a payment method should contain the control that adds a payment method.

Where this connects to everything else

Errors are one of three states that get designed last and look identical in a wireframe. Loading, empty, and failed are different situations that demand different responses from the user, and when they are rendered with the same neutral placeholder the interface has converted a clear problem into an open question. That is the same failure mode as treating latency as a backend concern: the system knows exactly what is going on, and declines to say.

It is worth spending real design time here for an unglamorous reason. Nobody meets your error states on a good day. They meet them at the moment the product has already failed them once, which is exactly when the difference between a sentence that helps and a sentence that shrugs decides whether they stay.


Respond

Corrections are welcome.

Nullhaus keeps a library, not a comment thread. If something here is wrong, out of date, or simply worth arguing with, send it. Substantive corrections are folded into the piece itself, with credit if you want it.

← Back

Thank you for your response. ✨

Received. Corrections are read by a person, and if this changes the piece, the piece changes.

Or write directly to contact@nullhaus.org



Everything Nullhaus publishes is free to read and free to reuse with attribution. Browse the whole library or join, free.

Discover more from Nullhaus

Subscribe now to keep reading and get access to the full archive.

Continue reading