Error Handling That Doesn’t Leave Users Stuck

The button spins. Then it stops. Nothing happens. The user clicks again. Now they have two orders, or none, and a support ticket that says “your site is broken.”

The code did handle the error. It console.error’d. It returned null. It swallowed a promise. From the system’s point of view, the error was handled. From the user’s point of view, the app lied.

Error handling is not try/catch coverage. It is what the human sees, what you log, and whether a retry is safe. This is how to design that path so people are not stuck, and so you can tell duplicate clicks from genuine double submits.

Start with the user’s question

When something fails, the user needs to know: did it work, can I try again, and do I need to do something?

“Something went wrong” answers none of those. It is acceptable as a last resort if you include a reference id. It is not acceptable as the only UI for “email already registered,” “card declined,” or “you are offline.”

Map failures into a small set:

  • Recoverable with the same action (timeout, 503). Say so. Offer retry.
  • Recoverable with a different action (validation). Point at the field.
  • Not recoverable here (banned, payment method dead). Say what to do next.
  • Unknown. Apologize, give an id, offer retry once, do not loop forever.

If you cannot map it, your API is returning a blob. Fix the API (see the REST article). The frontend cannot invent a kind from an empty 500.

Do not swallow the error

Empty catch {} is how outages look like success. At minimum, log with context: user id, request id, action name. Then decide the UI.

catch that returns a default empty list makes a dashboard look like the user has no data. Distinguishing “zero projects” from “failed to load projects” is the whole product. Use an error state, not an empty array, unless you are sure.

In async UI, an unhandled rejection is a blank page or a React error boundary. Error boundaries are good for unexpected render failures. They are not a substitute for handling a failed fetch next to the widget that failed.

Idempotency for anything that charges or creates

Retries are how you get double charges. The user retries because you showed a spinner until a timeout, then a generic error, and they did not know the first request succeeded.

Use an idempotency key for POST that creates orders or payments. Send the same key on retry. The server returns the same result. Stripe does this. Your API can too: a header Idempotency-Key stored for a day.

If you cannot change the server yet, disable the button until you have a definite success or failure, and make the failure message include “if you were charged, do not submit again; contact support with this id.” That sentence prevents a class of tickets.

GET retries are usually safe. PUT should be idempotent by definition. PATCH and POST need thought.

Timeouts are errors

A hung request is worse than a fast 500. Set timeouts. When they fire, tell the user the request may still be running. That is honest. “Failed” on a timeout that later succeeds is how duplicates happen if they resubmit.

On the server, when the client disconnects, stop work if you can. If you cannot (the charge already went out), persist the result so a retry with the same key returns success instead of charging again.

Validation is not an exception

User error is expected. Treat it as a branch, not as throw new Error. Return field errors. Focus the first invalid field. Do not toast a paragraph.

Client-side validation is a convenience. Server-side validation is the real gate. Show server field errors in the same place as client errors so the form does not feel like two products.

Logging without becoming the incident

Log the unexpected. Do not log every 401 on a public marketing page at error level. You will page people for scanners.

Include a request id in the response and in the log. When the user sends a screenshot of the id, you can find the stack. Without it, you have a timestamp and a shrug.

Do not log secrets, tokens, or full card numbers. Do not log entire payloads “just in case” if they contain PII. Log the code and the ids.

Error boundaries, fallbacks, and partial UI

A page with five widgets should not die because the recommendations widget 500’d. Isolate. Show the rest. Put the failure in the widget: “Recommendations unavailable.” A full-page crash for a sidebar is rude.

A global error boundary is still worth having for render bugs. Make it offer reload and a way to go home. Make it report the error. Do not make it a cute illustration with no action.

Offline: navigator.onLine is imperfect, but a failed fetch plus a network error should say you appear offline, not “internal server error.”

Background jobs and “we will email you”

If the work cannot finish in the request, say that up front. “We are generating the export. We will email you.” Then actually email, or the user will refresh and duplicate the job. Show a job status page if the wait is long.

Failures in jobs need a dead letter and a user-visible status, not only a log in a worker nobody watches.

A checklist for a new feature

  • What does success look like on screen?
  • What are the expected failures, field by field?
  • What is the unexpected failure UI, including an id?
  • Is retry safe? If not, idempotency or a warning.
  • Is the button protected against double click?
  • Does the empty state mean zero or error?
  • Who gets paged, at what log level?

If you cannot answer retry safety, do not ship the payment button.

Copy that does not blame the user

“Invalid input” on a date field they filled with their locale’s format is you, not them. Show the expected format. “Network error” when you returned 403 is a lie; they will retry forever. Map 403 to “you do not have access” and a way to request it if that is a product path.

Avoid humor on payment errors. Nobody wants a witty declined card.

Support as part of the design

If the user must contact support, give them the request id already selected, or a button that opens email with it in the subject. If support cannot search that id in logs, the id is decoration. Test the path once.

Partial success

A request that creates a user and then fails to send email should not look like a total failure if the user can log in. Tell them the account exists and email may be delayed, with a resend action. A rollback that deletes the user after they already saw success is worse. Decide the transaction boundary and show it.

Artikals keeps returning to this because users do not file tickets about your elegant Result type. They file tickets because the UI went quiet. Make errors loud to humans, precise to machines, and safe to retry. That is error handling. The rest is catch blocks.

Leave a Reply

Your email address will not be published. Required fields are marked *