Skip to content
aipatterns.dev

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 : cache

Participants

  • 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 degraded and 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

  1. 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.
  2. Pick the substitute per call site: cached last-known-good, static default, empty result, a second provider, or skip-and-continue in a loop.
  3. Bound staleness: a cache entry older than maxAge counts as a miss, so degradation has a shelf life.
  4. Mark degraded responses in the type (degraded: true), in metrics, and in logs - never return a substitute unmarked.
  5. Wire the trigger: fire the fallback when Timeout expires, Retry is exhausted, or the Circuit Breaker is open - not as a blanket catch around everything.
  6. Alert on the fallback rate. Degradation that lasts a day is an incident with better UX, not a steady state.
  7. 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: 2

The 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 degraded flag 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

Original writing and examples.