Design patternResilience Patterns
Fallback
Return a pre-decided lesser answer when a dependency cannot - stale cache, static default, partial result - so the journey continues, marked as degraded.
- Complexity
- Medium
- Seen in the wild
- Medium
- Also known as
- Graceful Degradation, Degrade
Intent
Decide in advance what a missing answer costs, and on failure return the substitute that costs least: the last-known-good value, a static default, an empty result, a queue-for-later. The caller's journey continues instead of failing - degraded, never silently wrong.
Forces
- Fail honestly vs fail usefully: an error is truthful but stops the journey; a default is useful but must not be mistaken for the real answer.
- Freshness vs availability: yesterday's recommendations beat an error page, but yesterday's inventory count may not.
- One substitute vs per caller: only the caller knows whether a missing answer is cosmetic or load-bearing.
- Degrade vs hide: a fallback that nobody can see turns an outage into a mystery.
Therefore
Give each call site a substitute decided in advance, return it when the dependency fails or times out, and mark the response as degraded so monitoring and users can tell.
The substitute is a product decision, not a code pattern: marketing copy can go stale, a payment cannot. Code exists to make that decision explicit and observable at every call site.
Structure
Diagram source (Mermaid)
classDiagram
class RecommendationSource {
<<interface>>
+recommendationsFor(userId) Recommendations
}
class PersonalizationService {
+recommendationsFor(userId) Recommendations
}
class CachedRecommendations {
-maxAge Duration
+get(userId) String[]
+put(userId, items)
}
class ResilientRecommendations {
-inner RecommendationSource
-cache CachedRecommendations
+recommendationsFor(userId) Recommendations
}
RecommendationSource <|.. PersonalizationService
RecommendationSource <|.. ResilientRecommendations
ResilientRecommendations --> RecommendationSource : inner
ResilientRecommendations --> CachedRecommendations : cacheParticipants
- Resilient wrapper (
ResilientRecommendations): tries the real source, and on failure serves the substitute and marks the response degraded. - Dependency (
PersonalizationService): the remote call that can fail. - Substitute (
CachedRecommendations): last-known-good values with a freshness bound; the code also holds a static default list for cache misses. - Caller: reads
degradedand can adjust (hide the section, show generic content, skip the call next time).
Use it when
- The data is nice-to-have: recommendations, avatars, follower counts, "customers also bought".
- Reads tolerate staleness with a known bound (prices valid for minutes, cached until refresh).
- A Circuit Breaker is open: serving cached data beats erroring every request.
- Batch jobs should skip-and-continue: one dead source must not fail a nightly export of twenty.
- Non-critical steps decorate a critical path (notifications, analytics) and must not fail the transaction.
Do not use it when
- The answer must be correct: payments, inventory commits, authorization checks, anything consumed as a financial record. Fail loudly instead.
- The substitute silently changes meaning. Returning zero as "no discount" when the truth is "unknown discount" is a business bug, not resilience.
- The dependency is the product. A fallback for the primary data of the page is a different, wrong product.
- Nothing marks the degradation. An unmarked fallback hides a chronic outage; pair it with a metric and an alert on fallback rate.
- A queue would do better. If the work must happen eventually (send the email, update the index), defer with Command and a durable queue rather than serving a substitute forever.
Application grill
Inspect first: the dependencies whose failure should degrade and what lesser answers already exist (a cache from cache-aside, static defaults, partial data); whether the UI has any degraded-state convention; feature-flag infrastructure.
Missing pieces: a fallback needs a source for the lesser answer - usually the cache, a bundled default, or a read replica. Feature flags control degraded UI; a flag service is justified when non-developers must toggle, otherwise environment configuration is enough.
Ask before you build:
- Scale: which dependencies may degrade, and can every fallback be on at once during a real outage?
- Performance: how stale may the fallback answer be before it is worse than an error?
- Reliability: how is the degraded state surfaced to users and metrics - a silent fallback is how outages hide?
- Budget: build per-dependency fallbacks or standardize one wrapper - who owns the second failure path for each?
- Implementation: when was each fallback last exercised in staging - a fallback that never ran is a hope?
Implementation steps
- For each call site, decide what a missing answer costs and who can tolerate what. This is a product conversation; the code only encodes its outcome.
- Pick the substitute per call site: cached last-known-good, static default, empty result, a second provider, or skip-and-continue in a loop.
- Bound staleness: a cache entry older than
maxAgecounts as a miss, so degradation has a shelf life. - Mark degraded responses in the type (
degraded: true), in metrics, and in logs - never return a substitute unmarked. - Wire the trigger: fire the fallback when Timeout expires, Retry is exhausted, or the Circuit Breaker is open - not as a blanket
catcharound everything. - Alert on the fallback rate. Degradation that lasts a day is an incident with better UX, not a steady state.
- Test the fallback path like a feature: stale cache, empty cache, and marked responses.
Example
A product page shows personalized recommendations. The personalization service fails; the page serves last-known-good recommendations from cache, or a static "popular this week" list for unknown users - every degraded response carries degraded: true so the page can label it and the dashboard can count it.
type Recommendations = { items: string[]; degraded: boolean };
interface RecommendationSource {
recommendationsFor(userId: string): Promise<Recommendations>;
}
// The substitute: last-known-good answers with a freshness bound.
class CachedRecommendations {
private readonly store = new Map<string, { items: string[]; at: number }>();
constructor(private readonly maxAgeMs: number, private readonly now = Date.now) {}
put(userId: string, items: string[]) {
this.store.set(userId, { items, at: this.now() });
}
get(userId: string): string[] | null {
const hit = this.store.get(userId);
return hit && this.now() - hit.at <= this.maxAgeMs ? hit.items : null;
}
}
// The static default for users with no cache entry.
const POPULAR_PICKS = ["best-seller-1", "best-seller-2", "new-arrival-1"];
// The dependency: a personalization service that answers once, then breaks.
class PersonalizationService implements RecommendationSource {
constructor(private successesLeft: number) {}
async recommendationsFor(userId: string): Promise<Recommendations> {
if (this.successesLeft-- <= 0) throw new Error("503 unavailable");
return { items: [`picks-for-${userId}`], degraded: false };
}
}
class ResilientRecommendations implements RecommendationSource {
constructor(
private readonly inner: RecommendationSource,
private readonly cache: CachedRecommendations,
private readonly onDegraded: () => void,
) {}
async recommendationsFor(userId: string): Promise<Recommendations> {
try {
const fresh = await this.inner.recommendationsFor(userId);
this.cache.put(userId, fresh.items);
return fresh;
} catch (e) {
this.onDegraded(); // count it; alert on the rate
const cached = this.cache.get(userId);
if (cached) return { items: cached, degraded: true };
return { items: POPULAR_PICKS, degraded: true };
}
}
}
const cache = new CachedRecommendations(60_000);
let degradations = 0;
const source = new ResilientRecommendations(
new PersonalizationService(1), // one good answer, then it fails
cache,
() => degradations++,
);
console.log(await source.recommendationsFor("u-1"));
// { items: [ 'picks-for-u-1' ], degraded: false }
console.log(await source.recommendationsFor("u-1"));
// { items: [ 'picks-for-u-1' ], degraded: true } <- served from cache
console.log(await source.recommendationsFor("u-2"), "degradations:", degradations);
// { items: [ 'best-seller-1', ... ], degraded: true } degradations: 2The wrapper has the same interface as the service, so it is also where a Timeout or retry would live - the fallback fires after those give up.
Trade-offs
Gains
- The journey continues during an outage; users see slightly worse content, not an error page.
- Non-critical failures stop failing critical paths.
- The
degradedflag turns silent rot into a visible, alertable signal.
Costs
- Every substitute is a product decision with a staleness or correctness risk attached.
- Unmonitored fallbacks hide chronic outages behind a calm UI.
- The wrapper adds a layer and a cache to own and to test.
- Wrong substitutes (zero, empty, "unknown") can corrupt decisions downstream.
Relations
- Retry exhausts the transient attempts first; the fallback answers when retry gives up.
- Circuit Breaker makes fallback a steady companion: while the breaker is open, every call serves the substitute immediately.
- Timeout provides the trigger - without deadlines, the fallback path never fires because the call never ends.
- Decorator: the wrapper is a decorator; several resilience wrappers (timeout, retry, fallback) stack around one interface.
- Strategy: when several substitutes exist (cache, second provider, static list), the choice of substitute is a runtime strategy the composition root picks.
Agent checklist
- Each call site's substitute is a deliberate product decision, not a default
catch. - Staleness has a bound (
maxAge), and expired entries count as misses. - Degraded responses are marked in the type and counted in metrics.
- Correctness-critical paths (payments, authorization, inventory commits) have no fallback.
- The fallback path has tests: stale cache, cold cache, and the marked response.
- An alert exists on the fallback rate, so degradation cannot become invisible.
See also
- RetryRe-attempt only retryable, repeatable operations a bounded number of times, honour Retry-After, check cancellation between attempts, and surface the last error.
- Circuit BreakerCount consecutive failures to a dependency, refuse calls while it looks broken, and let one probe test recovery - so a dead service cannot eat its callers.
- TimeoutGive every remote call a deadline shorter than the caller's own budget, cancel the work when it expires, and surface the timeout as a distinct error.
- DecoratorWrap an object in another object with the same interface that adds behavior before or after delegating, so features stack without subclassing.
- StrategyPut each interchangeable algorithm behind one interface so the caller picks a behavior at runtime without branching on it.
Original writing and examples.