Why Resilience Matters

Resilience is baked into the web's foundation. Browsers ignore invalid HTML and unsupported CSS, processing what they can and skipping the rest. JavaScript offers no such courtesy. When a script fails, we must catch the error and decide what happens next. That decision directly shapes the user experience.

A degraded experience doesn't have to be a dead end. A failed API request might make a section unusable, but the rest of the page can still deliver value. This principle—graceful degradation—is what separates a resilient UI from a brittle one. And it matters beyond technical polish. A feature that fails without explanation frustrates users and reflects poorly on the product. Reliability is a signal of quality, and users associate a smooth experience with a trustworthy brand.

Writing off failures as corner cases is tempting, but the web has many corners: different browsers, hardware, network conditions, and user preferences. The longer a system runs, the more likely something will go wrong.

Not All Errors Are Equal

Failures have a hierarchy, just like content. A broken total balance on a banking dashboard is more critical than a failed notification prompt. Categorizing errors by impact—borrowing from methods like MoSCoW prioritization—helps decide what the UI must surface.

Wireframe of a banking website. Black text on a white background. The left side displays the account balance of £500. The top right contains a notification (bell) icon and count of 3. Below the icon is a popup displaying the 3 unread items.
An example of primary versus secondary information. The account balance (£500) is primary information integral to the user experience, whereas unread notifications are a non-essential enhancement (secondary information). (Large preview)

Primary information failures demand transparency: let the user know something went wrong. For secondary failures, gracefully hide the degraded component and preserve the core experience. Bombarding users with every error message simply clutters the interface. Instead, let content hierarchy guide which failures surface and which are handled quietly behind the scenes.

Wireframe of a banking website. A red icon with error message reads: Sorry, unable to load your bank balance. The top right contains a notification (bell) icon.
When the account balance is unavailable we show an error message. When unread notifications are unavailable we simply remove the count and popup from the UI, whilst preserving the semantic link a href='/notifications' to the notification center. (Large preview)

A straightforward decision tree can determine whether to show an error message:

Decision tree with 2 leaf nodes that read (from left to right): Primary error? No: Hide degraded component, Yes: Show error message.
Primary errors should surface to the UI, whereas secondary errors can be gracefully hidden. (Large preview)
Two wireframes of different error states. The left one titled: Error message per failure, displays 3 red error notifications (1 for each failure). The right one titled: Single error message with action, shows a single error notification with a blue button below.
Just because 3 errors occurred (left) doesn’t automatically mean 3 error messages should be shown. An action, such as a retry button, or a link to the previous page helps guide users what to do next. (Large preview)

Defensive Coding First

Preventing errors beats recovering from them. The best error is one that never happens. While we cannot control third-party outages or user behaviour, we can stop assumptions creeping into our code.

Consider fetching data about debit cards. An optimistic approach assumes the endpoint returns an array of objects, each with a lastFourDigits field:

const debitCards = useDebitCards();

return (
  <ul>
    {debitCards.map(card => {
      <li>{card.lastFourDigits}</li>
    })}
  </ul>
);

Users end up testing our assumptions when the response deviates. A defensive rewrite verifies those assumptions inline:

const debitCards = useDebitCards();

if (Array.isArray(debitCards) && debitCards.length) {
  return (
    <ul>
      {debitCards.map(card => {
        if (card.lastFourDigits) {
          return <li>{card.lastFourDigits}</li>
        }
      })}
    </ul>
  );
}

return "Something else";

The same logic applies to third-party libraries. Calling stripe.handleCardPayment without checking it exists is optimistic:

stripe.handleCardPayment(/* ... */);

Verifying the method is present before using it is safer:

if (
  typeof stripe === 'object' && 
  typeof stripe.handleCardPayment === 'function'
) {
  stripe.handleCardPayment(/* ... */);
}

This is feature detection in its purest form. Checking the Clipboard API before offering copy or paste allows the UI to adapt ahead of time by hiding unsupported features:

if (navigator.clipboard) {
  /* ... */
}
Two black and white wireframes. The left one titled: Clipboard unavailable, displays 2 rows of numbers. The right one titled: Clipboard available, shows the same 2 numbers alongside a clipboard icon.
Only offer users functionality when we know they can use it. The copy to clipboard buttons (right) are conditionally shown based on whether the Clipboard API is available. (Large preview)

User behaviour also deserves guardrails. Double-clicking a submit button should not duplicate a non-idempotent request. Preventing resubmission during form submission mitigates the fallout of multiple requests:

Two black and white wireframes. The left one titled: Double-click = 2 requests, displays a form and button (labelled submit) above a console showing 2 XHR requests to the orders endpoint. The left one titled: Double-click = 1 request, displays a form and button (labelled submitting) above a console showing 1 XHR request to the orders endpoint.
Users should not be punished for their browsing habits or mishaps. Preventing multiple form submissions because of intentional or accidental double-clicks is easier than cancelling duplicate transactions at a later date. (Large preview)

For accessibility, prefer managing state in JavaScript over using the disabled attribute; exposing aria-disabled="true" keeps the button focusable while still preventing duplicate actions.

Recover, Fallback, or Acknowledge

Not every error is preventable. When one occurs, a decision tree helps determine the response:

Decision tree with 3 leaf nodes that read (from left to right): Recover from error? No: Fallback from error?, Yes: Resume as usual. The decision node: Fallback from error? has 2 paths: No: Acknowledge error, Yes: Show fallback.
Decision tree representing how we can respond to runtime errors. (Large preview)

First, ask whether recovery is possible. Retrying a failed GET request often succeeds due to intermittent network issues. Libraries like SWR include this out of the box. At Nutmeg, retries are limited to reads; writes like POST or DELETE are never retried, since duplicates risk unwanted mutations.

Second, if recovery fails, is there a fallback? A failed card payment could offer PayPal or Open Banking as alternatives:

Wireframe of a red error notification above a form. The error message reads: Card payment failed. Please try again, or use a different payment method. The text: different payment method is underlined denoting it's a link.
When something goes wrong offering an alternative helps users help themselves, and avoids dead ends. This is especially important for time sensitive transactions such as buying stock, or contributing to an ISA before the tax year ends. (Large preview)

Fallbacks need not be elaborate. Text depending on remote data can fall back to a simpler, static message when the request fails:

Two black and white wireframes. The left one titled: Remote data unavailable, displays a paragraph that reads: Make the most of your remaining ISA allowance for the current tax year. The right wireframe titled: Remote data available, shows a paragraph that reads: Make the most of your £16500 ISA allowance for April 2021-2022
UIs can adapt to what data is available and still provide value. The vaguer sentence (left) still reminds users that ISA allowances lapse each year. The more enriched sentence (right) is an enhancement for when the network request succeeds. (Large preview)

Finally, if neither recovery nor fallback is possible, acknowledge the error. Use the content hierarchy to judge importance, then inform the user with actionable guidance, like contacting support:

Two wireframes, each containing a red error notification. The left one titled: Unhelpful error message, displays the text: Something went wrong. The right one titled: Helpful error message shows a paragraph that reads: Sorry, unable to load your bank balance. Please try again, or. Below the paragraph is a list of the following items, phone us on 01234567890 8am to 8pm Mon to Fri, email us on support at email dot com and search ‘bank balance’ in our knowledge base
Avoid unhelpful error messages. The helpful error message (right) prompts the user to contact CS, including how (phone/ email) and what hours they operate to manage expectations. It’s not uncommon to provide errors with a unique identifier that users can reference when making contact. (Large preview)

Visibility Through Observability

A degraded experience is only half the story. Engineers need to know why the experience degraded, and that includes errors the user never sees.

Tools like Sentry and Rollbar automatically capture unhandled exceptions:

 A screenshot taken from Sentry’s online sandbox of a TypeError. An error message reads: Cannot read property func of undefined. Below the error is a stack trace of where the exception was thrown
A screenshot of an error captured in Sentry. (Large preview)

Setting these up costs little effort and pays off in faster mean time to acknowledge. The real value, though, comes from explicitly logging errors. Adding context and meaning makes troubleshooting far easier. Aim for non-technical teams to understand the message:

Grey text on white background showing a function logging an error. The 1st function argument reads: Payment Bank transfer – Unable to connect with ${bank}. The 2nd argument is the error. Below the function are 3 labels: Domain, Context, and Problem.
Naming conventions help standardise explicit error messages, which make them easier to find/ read. The diagram above uses the format: [Domain] Context — Problem. You needn’t be an engineer to understand a bank transfer failed, and that the payments teams should investigate (if they aren’t already doing so). (Large preview)

The Stripe example benefits from an explicit else branch for logging failed preconditions:

if (
  typeof stripe === "object" &&
  typeof stripe.handleCardPayment === "function"
) {
  stripe.handleCardPayment(/* ... */);
} else {
  logger.capture(
    "[Payment] Card charge — Unable to fulfill card payment because stripe.handleCardPayment was unavailable"
  );
}

This defensive check can run when a component mounts—before the error—giving the UI more time to react.

Observability exposes weaknesses. Once located, harden the code or wrap risky third-party integrations in operational feature flags. When an outage is discovered—hopefully by monitoring rather than users—communicate clearly about the issue. Forewarning tempers frustration, and transparency helps users plan around the problem:

Two black and white wireframes. The left one titled: Kill switch off, displays 3 form fields above a blue button. The right one titled: Kill switch on, shows the text: Download PDF next to a download icon.
Not all fallbacks need to be digital. This is especially true for processes that already involve manual steps, such as transferring an ISA from one bank to another. When everything is operational (left) users submit an online form that populates a PDF they print and sign. When the third-party suffers an outage or is down for maintenance (right) a kill switch allows users to download a blank PDF form they can fill in (by hand), print and sign. (Large preview)
Wireframe of a blue banner atop of a page. The banner reads: We’re currently experiencing problems with online payments and are working on resolving the issue
Avoid offloading observability to end users. Finding and acknowledging issues before customers do leads to a better user experience. The information banner above is clear, concise, and reassures users that the issue is known about, and a fix is incoming. (Large preview)

Learning From Incidents

Errors are uncomfortable, but they are learning tools. Success in complex systems comes from confronting mistakes and creating an environment where failure is safe to discuss. Document incidents as they happen, much like aviation's black boxes capture every flight detail.

At minimum, prior documentation shortens the mean time to repair should the same issue recur. Write root cause analysis reports that are honest and discoverable. Cover what went wrong, the impact, relevant technical details, and follow-up actions. Those reports become a trail that helps current and future engineers avoid repeating history.

Building With Failure in Mind

Resilient UI development starts with acknowledging that the web is inherently unreliable. Networks drop, APIs change, and devices run out of memory. Teams that plan for these failures from the design stage treat reliability as a feature, not a patch. Proactive preparation beats reactive debugging from every angle: business metrics, customer trust, and the developer experience itself, which suffers when engineers are stuck hunting regression instead of shipping.

  • Interfaces should degrade gracefully, offering meaningful value even when full functionality is unavailable.
  • Challenge every assumption about state, connectivity, or input; ask “what can break here?” before writing code.
  • Rank errors by impact and severity; a cosmetic glitch is not a data-integrity incident and deserves a different response.
  • Prevention beats reaction. Validate inputs, guard against null, and write assertions before wiring up error handlers.
  • On any failure, first ask whether a graceful recovery path or a safe fallback exists, then build it.
  • User-facing messages must clearly explain what happened and suggest an actionable next step, not dump stack traces.
  • Engineers need their own visibility: wire errors into monitoring and alerting from day one.
  • Internal error logs and reports should carry meaningful context — the component, the action, the attempted payload — so a colleague can act without a long investigation.
  • Treat every incident as data. Document lessons and share them so the whole team builds on what was learned.
Smashing Editorial