Recovery and degradation: evict state, back off on retry, fail honestly
Recovery is state eviction: resetKeys or reset() remount the crashed subtree so corrupted state dies. Retry transient failures with exponential backoff and jitter, never in a tight loop. Degrade per widget so the page lives; fail the whole page only when integrity is at stake.
The quotes API browned out for two minutes — elevated 5xx, nothing dramatic. Then the client made it dramatic. The trading dashboard’s rates widget had a boundary with a Retry button, and Retry was wired as “call the fetch again, immediately, on every click and on every error”. Thirty thousand open dashboards started hammering a degraded API in lockstep, each failure scheduling the next attempt one second later. Server load graphs show the API receiving eight times its normal traffic from its own clients at the exact moment it could least afford it — a two-minute brownout stretched into a forty-minute outage, caused by the recovery code. Meanwhile a second bug hid in the same widget: when a malformed tick crashed it, clicking Retry re-rendered the same tree against the same poisoned Redux state and crashed again within one frame. Users clicked Retry, watched the fallback blink, and filed tickets that said “the retry button is decorative”. Two lessons in one incident: recovery means evicting the state that crashed, and retrying means deliberately not retrying right away.
By the end of this lesson you’ll know why a naïve Retry button can make an outage eight times worse, and the exact mechanics of state eviction that make a real reset work.
Recovery is state eviction, not re-rendering
When a boundary catches, the subtree below it is unmounted — but the cause of the crash usually lives on: in a parent’s state, in a store, in a URL param. The crash is a symptom; the corrupted or unexpected state is the disease. So “retry” cannot mean “render the same thing again” — same state, same render, same throw, often within a single frame. Recovery means remounting the subtree with the poisoned state evicted or replaced.
react-error-boundary gives you both directions of that move:
<ErrorBoundary
FallbackComponent={QuoteFallback}
resetKeys={[symbol]} // symbol changes → boundary auto-resets, subtree remounts
onReset={() => refetchQuote()} // clear the cause along with the crash state
>
<QuotePanel symbol={symbol} />
</ErrorBoundary>resetKeys is the declarative direction: when any value in the array changes — the user picks another symbol, the route param updates — the boundary leaves its error state and remounts children. The crash was about the old input, so new input is the natural recovery trigger. resetErrorBoundary() (handed to the fallback) is the imperative direction: a Retry button. But pressing it only clears the boundary’s own error flag and remounts children with fresh local state — if the poison lives outside the subtree, you must also fix the cause in onReset: refetch, clear the cache entry, reset the store slice. Plain React without the library does the same thing with a key: bump key={attempt} on the subtree and React unmounts the old instance, state and all, and mounts a new one. Remounting is the point — a corrupted child’s state must die, and key identity is how React decides what dies.
A fallback's Retry button calls resetErrorBoundary(). The widget crashes again within one frame, every time. What is the most likely mechanism?
Retry with backoff — or become the second outage
When you add a Retry button, you are making a distributed-systems decision on behalf of every client that will ever open this page simultaneously. Transient failures — a 503, a dropped connection, a brownout — deserve a retry. But the Hook’s incident is the standard failure mode of naive retry: thousands of clients retrying at the same fixed interval form a synchronized wave (the thundering retry), and the load you add arrives precisely when the service is least able to absorb it. Client-side retry is a distributed-systems decision made in UI code. The discipline is the same as on the backend:
async function retryFetch(url, { tries = 3, base = 1000, cap = 30_000 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(url);
if (res.status >= 500) throw new Error("HTTP " + res.status);
return res;
} catch (err) {
if (attempt + 1 >= tries) throw err; // budget exhausted → boundary
const delay = Math.min(cap, base * 2 ** attempt); // 1s, 2s, 4s … capped
await new Promise((r) => setTimeout(r, delay + Math.random() * delay)); // jitter
}
}
}Three rules hide in those ten lines. Exponential backoff spreads the wave over time — 1s, 2s, 4s instead of a fixed metronome. Jitter desynchronizes the clients — without the random term, thirty thousand dashboards that failed together retry together forever. A retry budget ends the story: after the budget, throw to the boundary and let a human-paced action (the Retry button) become the next attempt — a user clicking is naturally rate-limited and naturally jittered. And only retry what is plausibly transient: a 503 yes, a 400 from a malformed request no — retrying a deterministic failure is a tight loop with extra steps.
Partial degradation: error budgets, applied to pixels
A widget boundary does more than catch — it converts a crash into a degraded mode while the rest of the page lives. That is error-budget thinking applied to UI: not every region of the screen has the same availability requirement, so not every region should fail with the same blast radius. A recommendations rail can fail to an empty slot and nobody loses money. A quotes widget can show the last successful tick, visibly marked stale, which beats both a spinner and a hole. The page-level question becomes: which regions are decorative, which are degradable with stale data, and which are integrity-critical?
The fallback itself owes the user three honesty rules. Say what broke at the user’s granularity — “Quotes are unavailable” beats “Something went wrong”, and both beat an infinite spinner, which is a lie about progress. Offer the real next action — a Retry wired through reset-plus-cause-eviction, not a decorative one. And keep the exits alive — navigation, the rest of the page, the data the user already entered. A fallback that traps the user is a second failure on top of the first.
Then there is the case where partial survival is the wrong goal. If the broken thing is integrity-critical — the auth context that decides whose data renders, the payment step that decides how much gets charged, the cart total feeding the order — degrading politely around it risks showing user A the session of user B, or charging against a stale total. There you fail the page intentionally: full-route fallback, no cheerful “some features may be unavailable”. Availability is a feature; correctness is the product.
During a quotes-API brownout, which client retry policy adds the least load at the worst moment while still recovering quickly?
▸Why this works
Why remount instead of asking the crashed component to repair itself? Because React cannot know how much of the subtree’s state participated in the crash — a reducer that produced NaN, a memoized selector caching a poisoned slice, a ref holding a dead DOM node. Any of it might re-trigger the throw. Unmounting is the only operation with a guarantee: all local state, effects and refs of the old instance are gone, and the new mount starts from props and initial state — the same reasoning as process restart in supervision trees (Erlang’s “let it crash”) rather than in-place repair. It also explains the limit of reset: a remount only purges state inside the boundary. State that lives above it — stores, caches, URL — survives by design, which is exactly why onReset exists and why reset without cause-eviction loops.
- 01Explain why recovery from a boundary catch requires a remount, and the two triggers react-error-boundary offers.
- 02State the three rules of client-side retry and the page-level degradation decision that frames them.
A boundary catching the error is the beginning of recovery, not the end. The crash is usually a symptom of state — in the subtree, in a store, in a URL param — so re-rendering the same tree against the same state re-throws within a frame; real recovery is state eviction, and remounting is how React evicts: the old instance’s state, effects and refs are destroyed and the new mount starts from initial values. react-error-boundary packages both triggers — resetKeys auto-resets when the inputs the crash was about change, and resetErrorBoundary gives the fallback an imperative Retry — while onReset is where you evict causes that live above the boundary, since a remount only purges what is inside it. Retrying the underlying operation is a distributed-systems decision made in UI code: retry only plausibly transient failures, space attempts with exponential backoff and jitter so synchronized client waves do not hammer a degraded API into a real outage, and stop after a small budget, letting the boundary fallback hand the pacing to the human. Around all of this sits the degradation map of the page — error-budget thinking applied to pixels: decorative regions fail to empty slots, degradable regions show visibly stale data, and the fallback owes the user honesty in three parts: name what broke, offer a Retry that actually evicts the cause, keep navigation and entered data alive. The exception proves the design: when the broken component is integrity-critical — auth context, payment step, cart total — partial survival is the dangerous option, and failing the whole page intentionally is the senior move, because availability is a feature but correctness is the product. Now when you wire a Retry button, you’ll reach for onReset to evict the cause, exponential backoff with jitter to protect the service, and a budget that hands the next attempt to the user — not a tight loop.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.