TL;DR
Networks drop and APIs time out. Instead of assuming success and handling failure, you assume failure and design for recovery. You can’t prevent every failure, but you can keep your app working when one happens.
Core principles:
Hope isn't a strategy.
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. On the web, these are normal conditions rather than edge cases you can get to 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 turns this approach around. 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. It’s impossible to prevent every failure, so what you can control is 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: The system keeps working despite failures
- Data table renders and skips the malformed cell
- Dashboard shows the widgets that loaded and hides the ones that failed
- Users might not notice anything went wrong
Graceful Degradation: Some functionality instead of none
- Image gallery shows thumbnails when the high-resolution images fail to load
- Search shows cached results when live search is unavailable
- Rich editor falls back to a plain textarea
Visible Recovery
Error Recovery: The system gets back to a working state after a failure
- Form retries the submission after a network timeout
- Video player rebuffers after the connection drops
- Sync process resumes when the device comes back online
Failure Transparency: Making failures visible and understandable
- Clear error messages explaining what went wrong
- Guidance on what users can do
- Honest acknowledgment of the problem, with a way forward
Each level suits different contexts, and most of the work is choosing the right response for each kind of failure your app might run into. Some failures should be invisible, while others need the user to notice or to act.
All four levels treat failure as a normal state that you design for from the start, instead of an exception you deal with later.
Things will go wrong, so the part you control is what your users see when they do.
The Brittleness of Happy-Path Design
A lot of code assumes success, with developers writing the flow for when data loads and 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 can crash the entire page instead of just the section that needed that data, and a malformed response might throw an exception that breaks unrelated components.
These problems build on each other. Components that assume their data arrives correctly can pass corrupt data downstream, and missing error boundaries let exceptions bubble up and crash the application. Loading states that never time out can leave users unsure whether to wait or refresh, and unvalidated state can drift into combinations that should never be possible.
A typical e-commerce checkout is a good example. With happy-path design, you build 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 the user clicks “confirm” but before the confirmation arrives? 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 can't tell them, because the team never planned for that case.
The alternative is designing with failure as a primary concern from the start, because that’s the environment we’re building for.
Defensive Architecture
With defensive architecture, you assume that anything can fail.
Defensive Principles
This defensive approach affects your whole architecture.
Every input is suspect. Data from users can be malicious, and data from APIs or your own storage can be malformed or out of date. That’s why validation belongs at the boundaries: your API “should” return a certain format, 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. The code at each boundary needs to check what crosses it.
Every operation needs a timeout. Network requests can hang forever, and promises can never resolve. Users hate uncertainty more than failure. If something takes too long, a timeout treats it as a failure and the user gets a message saying what happened.
Every dependency needs a fallback. The CDN can go down, or a third-party script can fail to load. Your critical features shouldn’t depend on things you don’t control unless there’s a fallback, such as cached data or a simpler version of the feature. Showing users something is almost always better than showing them nothing.
In practice, that means you design components with explicit failure states, and your data fetching includes timeout and error handling from the start. Your state management accounts for partial and invalid data, and you plan each feature with its failure cases in mind.
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, and a failing comment section shouldn’t stop someone reading the article. Each error boundary marks an area where a failure can happen without spreading outward.
Where you put error boundaries is an architectural decision. With too few, failures spread too far, and with too many, the application turns into a patchwork of disconnected fallbacks.
You’ll get the most out of error boundaries when you put them around sections that can fail independently. 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 should follow the sections that your users already think of as separate.
State isolation is another way to contain failure. When state is shared broadly, bad data spreads to everything that uses 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, your app might stop trying for a while, and if a component keeps crashing, the app might stop 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”, which is too vague
- Not “Error 500: Internal Server Error”, which is too technical
- But “We couldn’t save your changes because the network connection was lost”
Agency: What can they do about it?
- Next steps they can actually take
- A way to retry, when retrying makes sense
- Control over their own situation
Preservation & Path Forward
Preservation: Is their work safe?
- “Your work is saved locally and will sync when you’re back online”
- User input that’s never lost without a warning
- Drafts that auto-save and form data that’s kept
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 that even the developers don’t know 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 and tells users their work is safe. Users can stop worrying and either fix their connection or keep working.
Different failures need different messages. Temporary failures that clear up without the user doing anything 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, while system failures outside the user’s control need an acknowledgment and, if you have one, an estimate of when they’ll be fixed.
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. A progress indicator tells them how much longer it might take, and a cancel option lets them stop. “Still loading…” after thirty seconds is better than a spinner with no explanation, 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 in your app should survive page refreshes, and drafts should auto-save. Anything a user has typed or uploaded needs protecting. Local storage and IndexedDB both work for this. You should 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 be part of your 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 and error 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. When your parent components render children, you need a plan for when a child fails. Wrapper components can provide default error boundaries, and layout components can handle the empty space when content components fail.
State management in resilient systems accounts for invalid and partial states. Not every combination of state values is valid, so resilient systems either make invalid states impossible to represent or handle them explicitly. State machines and explicit state modeling help here, because they make the possible states visible and control how the app moves from one state to another.
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 if your failure handling isn’t tested, it might not work when you need it.
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?" | The Resilient Mindset Asks: "What happens when this fails?" |
|---|---|
|
|
This doesn’t mean over-engineering. Not every failure needs handling, and you still have to ship. Your job is to identify which failures are likely and which do the most damage, then put the effort there.
Common failures need thorough handling: network interruptions, slow responses, invalid data, and 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. You should 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 worst 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:
- Failure is normal: Networks drop and APIs time out, so you design for it
- Contained failures: Error boundaries and state isolation stop one failure from bringing down everything else
- Designed failure experiences: Users need understanding, agency, preservation, and a path forward
- Resilience from the start: Components and state are built to allow for failure
- Tested failure paths: Error handling gets tested as thoroughly as success paths
- Conscious trade-offs: Not every edge case needs handling, but you know which ones 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:
- Foundations: The Progressive Enhancement Mindset, Accessibility as a Foundation
- Guides: Managing Async UX States, Building with Progressive Enhancement
- Patterns: Error Boundary, Error Handling, Loading State, Retry Logic
External Resources:
- Release It! by Michael Nygard - Design patterns for production-ready software
- Error Handling in React - React’s error boundary pattern
- Resilient Web Design - Jeremy Keith’s book on building websites that keep working when things fail
- Fault Tolerance Patterns - Martin Fowler on distributed systems resilience