Skip to main content
Saved
Foundation

Resilience by Design

Build systems that keep working when things fail, by designing for failure and recovery from the start instead of adding error handling at the end.

Den Odell
By Den Odell Added
In this article
  1. TL;DR
  2. Accepting Reality
  3. Defining Resilience
  4. Invisible Handling
  5. Visible Recovery
  6. The Brittleness of Happy-Path Design
  7. Defensive Architecture
  8. Defensive Principles
  9. Containing Failure
  10. The User Experience of Failure
  11. Understanding & Agency
  12. Preservation & Path Forward
  13. Building Resilience In
  14. The Mindset Shift
  15. The Optimistic Mindset
  16. The Resilient Mindset
  17. Summary
TL;DR

TL;DR

Networks drop, APIs time out, and scripts fail to load. Instead of assuming success and handling failure, assume failure and design for recovery. You can’t prevent every failure, but you can keep the app working when one happens.

Core principles:

Design for failure first, not as an afterthought
Contain failures: one broken part shouldn’t crash everything
Write clear error messages: users need to know what happened and what they can do
Preserve user work: never lose what users have typed or uploaded

Hope isn't a strategy.

— Traditional Engineering Wisdom

Accepting Reality

Things will break: networks will drop, APIs will time out, users will click buttons twice, data will arrive malformed, servers will return errors, and JavaScript will fail to load. These are normal conditions on the web, not edge cases to handle eventually.

Yet many applications are designed for the happy path. Teams build features assuming everything works, then add error handling as an afterthought, and the software breaks in unpredictable ways once real users and real networks get involved.

Resilience by design inverts this approach. Instead of assuming success and handling failure, we assume failure and design for recovery.

Instead of asking “what should happen when this works?” we ask “what happens when this fails?” That sounds like a small change, but it changes how we structure the whole system.

The web is distributed and asynchronous, and it runs on devices and networks we don’t control. Designing for that produces applications that keep working and that users can trust, even when individual pieces fail.

Defining Resilience

Resilience is the ability to handle failure without falling over and to recover when possible. Preventing every failure is impossible, so the aim is to control what happens when one occurs.

Resilience exists on a spectrum, with fault tolerance at one end: the system keeps working when a component fails. A data table still renders when one cell’s data is malformed, or a dashboard displays the widgets that loaded even when some fail. The app absorbs the failure, and users might not notice.

The Resilience Spectrum

Invisible Handling

Fault Tolerance: System continues functioning despite failures

  • Data table renders and skips the malformed cell
  • Dashboard shows available widgets, hides failed ones
  • Users might not notice anything went wrong

Graceful Degradation: Reduced functionality rather than none

  • Image gallery shows thumbnails when hi-res fails
  • Search shows cached results when live search unavailable
  • Rich editor falls back to plain textarea

Visible Recovery

Error Recovery: System returns to working state after failure

  • Form retries submission after network timeout
  • Video player rebuffers after connection interruption
  • Sync process resumes after going offline

Failure Transparency: Making failures visible and understandable

  • Clear error messages explaining what went wrong
  • Guidance on what users can do
  • Honest acknowledgment with path forward

Each level suits different contexts, and most of the work is choosing the right response for each kind of failure. Some failures should be invisible, some need the user to know about them, and some need the user to do something.

All four levels treat failure as a state to design for, not an exception to deal with later.

Things will go wrong, so the part we control is what the user sees when they do.

The Brittleness of Happy-Path Design

A lot of code assumes success, with developers writing the flow for when data loads, when APIs respond, when users behave predictably. Error handling, if it exists, is an afterthought: a catch block that logs something and maybe shows a generic error message.

This approach creates brittle systems where a single failure takes down everything around it. An API timeout crashes the entire page, not just the section that needed that data. A malformed response throws an exception that breaks unrelated components. A slow network leaves users staring at spinners that never resolve.

These problems build on each other. Components that assume their data arrives correctly pass corrupt data downstream. Missing error boundaries let exceptions bubble up and crash the application. Loading states that never time out leave users unsure whether to wait or refresh. Unvalidated state drifts into impossible configurations.

Consider a typical e-commerce checkout. Happy-path design builds the flow: enter shipping address, select payment method, review order, confirm purchase. But what happens when the address validation API is slow? When the payment processor returns an unexpected error code? When the user’s session expires mid-checkout? When the network drops after clicking “confirm” but before receiving confirmation? Each of these needs its own design, covering what the user sees as well as how the code handles it.

When things break unexpectedly, users feel anxious and confused. Did I do something wrong? Is my data lost? A happy-path app has no answer because nobody planned for the question.

The alternative is designing with failure as a primary concern from the start, because that’s the environment we’re building for.

Defensive Architecture

Defensive architecture assumes that anything can fail.

Defensive Principles

Every input is suspect. Data from users can be malicious or malformed. Data from APIs can be incomplete or incorrectly shaped. Data from your own storage can be corrupted or outdated. That’s why validation belongs at the boundaries: your API “should” return a certain shape, but that doesn’t mean it always will.

Every boundary is a failure point. Component boundaries, API boundaries, storage boundaries and module boundaries are all places where one side sends something the other side didn’t expect, or a call never gets through. Each boundary needs to check what crosses it.

Every operation needs a timeout. Network requests can hang forever, promises can never resolve, and animations can fail to complete. Users hate uncertainty more than failure. If something takes too long, it’s treated as a failure: a timeout, a progress indicator, and eventually a message telling the user what happened.

Every dependency needs a fallback. The CDN can go down, the API can be slow, and a third-party script can fail to load. Critical functionality shouldn’t depend on things you don’t control without a fallback: cached data, default values, or reduced functionality. Something is almost always better than nothing.

This defensive approach affects the whole architecture. Components are designed with explicit failure states. Data fetching includes timeout and error handling from the start. State management accounts for partial and invalid data. Every feature includes thought about what happens when it doesn’t work.

Containing Failure

Resilient systems also contain failure, so a problem in one part of the system doesn’t break unrelated parts.

Error boundaries are the main tool for containment. They wrap sections of the application and catch errors thrown inside them, displaying fallback UI instead of crashing the entire tree. A broken widget shouldn’t take down the dashboard, a failing comment section shouldn’t stop someone reading the article, and a corrupt avatar shouldn’t crash the profile page. Each error boundary marks an area where a failure can happen without spreading outward.

The placement of error boundaries is an architectural decision. Too few, and failures cascade too far. Too many, and the application becomes a patchwork of disconnected fallbacks.

Error boundaries work best around sections that can fail on their own. A settings page might have boundaries around each section, so if notification preferences fail to load, account settings still work. A social feed might have boundaries around each post, so if one post’s media fails, other posts still display. The boundaries follow the sections users already see as separate.

State isolation is another way to contain failure. When state is shared broadly, bad data spreads to everything that reads it, but when state stays where it’s used, problems stay local. A component that manages its own state can fail without affecting its siblings, and a feature that owns its data can break without corrupting global state. The same boundaries that separate concerns also contain failures.

Circuit breakers stop repeated failures from spreading. If an API call fails three times, the app stops trying for a while, and if a component keeps crashing, the app stops rendering it. A circuit breaker treats a persistent failure differently from a temporary one and backs off instead of sending more requests to a broken system. That gives the server time to come back online, or the user time to move to a different part of the application.

Used together, these strategies give you defense in depth, since no single mechanism handles everything.

The User Experience of Failure

Resilience is a user experience concern as well as a technical one, because users only see how the failure appears to them, not how the code handled it.

Help Users Through Failures

Understanding & Agency

Understanding: What happened?

  • Not “Something went wrong” (too vague)
  • Not “Error 500: Internal Server Error” (too technical)
  • But “We couldn’t save your changes because the network connection was lost”

Agency: What can they do about it?

  • Provide actionable next steps
  • Offer retry options when appropriate
  • Give users control over their situation

Preservation & Path Forward

Preservation: Is their work safe?

  • “Your work is saved locally and will sync when you’re back online”
  • Never lose user input without warning
  • Auto-save drafts and preserve form data

Path Forward: How do they continue?

  • Clear guidance on next steps
  • Estimated resolution times when known
  • Alternative actions if available

Generic error messages give users none of these. “Something went wrong” doesn’t say what happened or what to do next, and it leaves users anxious. Technical error messages are worse: showing stack traces or error codes to non-technical users suggests nobody knows what happened.

A good error message is specific and tells the user what they can do. “We couldn’t save your changes because the network connection was lost. Your work is saved locally and will sync when you’re back online.” This explains what happened, tells users their work is safe, and says what happens next. Users can stop worrying and either fix their connection or keep working.

Different failures need different messages. Temporary failures that recover on their own might not need a message at all, just a small indicator that syncing is paused. User-caused errors need clear guidance on what to fix. System failures outside user control need acknowledgment and estimated resolution if possible.

Loading states belong here too, because a spinner that never stops is a failure as far as the user is concerned. Users need to know whether to wait or give up. Progress indicators, timeout messages, and cancel options tell them how the wait is going and let them leave it. “Still loading…” after thirty seconds is better than a spinner that never stops, and “This is taking longer than usual. You can wait or try again” lets the user choose.

Losing something a user typed is one of the fastest ways to lose their trust, so preserving user work comes first.

Form data should survive page refreshes. Drafts should auto-save. Submissions should retry on failure. Anything a user has typed or uploaded needs protecting. Local storage, session storage, and IndexedDB all work for this; the decision is to save to them as the user works rather than assuming the submission will succeed.

Building Resilience In

Resilience is hard to add at the end, so it needs to shape the architecture from the beginning.

Data fetching in resilient systems treats error states, loading states, timeout handling, and retry logic as part of the fetch from day one, instead of fetching data first and figuring out errors later. More and more libraries that manage server state build this in, providing loading, error, and stale states alongside the data itself.

That means designing APIs and data structures that allow for uncertainty. A user profile becomes { data, isLoading, error, lastFetched } instead of { name, email, avatar }. The component that renders the profile receives all of this and can decide what to display in each state.

Component design in resilient systems includes failure props and fallback rendering. A UserProfile component takes user data and also handles the case when that data is unavailable. The component’s API makes failure handling explicit instead of relying on consumers to remember to wrap it in error handling, so each component handles its own failures.

The same applies to composition. Parent components that render children need a plan for when a child fails. Wrapper components can provide default error boundaries, and layout components can handle gaps when content components fail.

State management in resilient systems accounts for invalid and partial states. Not every combination of state values is valid; resilient systems make invalid states unrepresentable or handle them explicitly. State machines and explicit state modeling help here, making the possible states visible and ensuring transitions between them are controlled.

Testing in resilient systems exercises failure paths as thoroughly as success paths. What happens when the API returns 500? What happens when data is malformed? What happens when the operation times out? What happens when the user is offline? These tests are as important as the ones that check things work when everything goes right, because untested failure handling may not work.

The Mindset Shift

Building resilient systems requires a shift in how we think about development.

Two Approaches to Building Software

The Optimistic Mindset

Asks: “What should happen when this works?”

  • Designs the happy path first
  • Grudgingly handles errors later
  • Produces code that works in demos
  • Breaks unpredictably in production
  • Leaves users confused when things fail

The Resilient Mindset

Asks: “What happens when this fails?”

  • Designs for failure first
  • Implements success as the optimistic path
  • Produces code that handles reality
  • Degrades gracefully under stress
  • Keeps users’ trust when things go wrong

This doesn’t mean over-engineering. Not every failure needs handling, and teams still have to ship. The work is identifying which failures are likely, which do the most damage, and which are easy to handle, then putting the effort there.

Common failures need thorough handling: network interruptions, slow responses, invalid data, user mistakes. Users run into these all the time. Rare, severe failures might only need a simple fallback, such as a friendly error page instead of a blank screen when everything breaks. Very unlikely edge cases might be fine to ignore: if something needs a perfect storm of conditions, letting it fail can be a reasonable choice.

These decisions need to be deliberate, so you know which failures you’re handling and which you’re accepting. Understand how far each failure can spread. Design the user experience of failure as carefully as the user experience of success.

Progressive enhancement and resilience by design come from the same idea: ideal conditions aren't guaranteed, so you design for the worse case first.

Progressive enhancement handles browsers that lack a capability, and resilience handles failures at runtime. An application built with both keeps working on real devices and real networks.

Summary

Resilience by design changes how we build for the web:

  1. Assume failure is normal: Networks drop, APIs time out, and data arrives malformed, so design for it
  2. Contain failures: Error boundaries and isolation prevent cascading collapse
  3. Design failure experiences: Users need understanding, agency, preservation, and a path forward
  4. Build resilience from the start: Data structures, components, and state that accommodate failure
  5. Test failure paths: Exercise error handling as thoroughly as success paths
  6. Make conscious trade-offs: Not every edge case needs handling, but know what you’re accepting

Failures will happen on the web whatever you do. Resilience is about what users see when they do: an app that keeps working and keeps their data, or one that breaks and loses it.


Related Content:

External Resources:

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.