Graceful Degradation: Systems That Bend Instead Break
Design systems that maintain core functionality when components fail through fallback strategies, degradation modes, and progressive service levels.
Graceful degradation means designing systems that keep core functionality working when parts fail. The key was classifying features into tiers (critical, essential, enhanced, nice-to-have) and building fallbacks at each level — cached data, default values, and static content all served as fallbacks when services failed. Circuit breakers detected when to stop calling failing services, while bulkheads prevented cascading failures from overwhelming the system. The goal was survival rather than perfection: a system that degraded gracefully was worth more than one that failed catastrophically.
Introduction
Most engineers think about reliability as keeping everything running. That mindset leads to over-engineering. Not every feature needs 99.99% uptime. Your recommendation engine does not need the same uptime as your payment processing.
Graceful degradation starts with understanding what your users actually need versus what they want. The distinction matters.
Core functionality: the reason users came to your site. If this breaks, users leave and do not come back.
Enhanced functionality: features that improve the experience but are not essential. Users might miss them but will still accomplish their primary goal.
When you design for graceful degradation, you accept that enhanced functionality will fail. You plan for it. You make sure core functionality never depends on enhanced functionality.
Designing for Degradation
Feature Flags as Load Shedding
Feature flags let you disable features under load. When your system is under stress, you can turn off recommendation engines, social features, or analytics. These features consume resources but do not drive core revenue.
def get_product_page(product_id: str, request_context: RequestContext):
# Core functionality - always enabled
product = product_service.get(product_id)
# Enhanced functionality - check feature flags
if feature_flags.is_enabled("recommendations", request_context):
recommendations = recommendation_service.get(product_id)
else:
recommendations = []
if feature_flags.is_enabled("social_proof", request_context):
reviews = review_service.get_for_product(product_id)
social_count = social_service.get_share_count(product_id)
else:
reviews = []
social_count = 0
return ProductPage(
product=product,
recommendations=recommendations,
reviews=reviews,
social_count=social_count
)
When the system is healthy, all features run. When load increases, you disable features via configuration. No code deployment needed.
Circuit Breakers as Gatekeepers
Circuit breakers prevent failures from cascading. When a downstream service starts failing, the circuit breaker opens and stops calling that service. Your code gets an immediate error instead of waiting for a timeout.
Circuit breakers work with graceful degradation because they give you a clear signal: this service is unavailable. You can then decide what to return instead.
See the Circuit Breaker Pattern article for implementation details.
Combine circuit breaker state with degradation mode:
from enum import Enum
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class DegradedCircuitBreaker:
def __init__(self, failure_threshold=5, timeout=60):
self.failure_count = 0
self.last_failure_time = None
self.state = CircuitState.CLOSED
self.failure_threshold = failure_threshold
self.timeout = timeout
self.degraded_mode = False
def call(self, func, fallback=None, *args, **kwargs):
if self.state == CircuitState.OPEN:
if fallback:
return fallback(*args, **kwargs)
raise CircuitOpenError("Circuit is open")
try:
result = func(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure()
if fallback:
return fallback(*args, **kwargs)
raise
def _on_success(self):
self.failure_count = 0
self.state = CircuitState.CLOSED
def _on_failure(self):
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
def to_degraded(self):
self.degraded_mode = True
class CircuitOpenError(Exception):
pass
When the circuit is OPEN, the fallback fires right away instead of timing out. Put the circuit breaker into degraded mode and you skip the slow path entirely.
Bulkheads for Isolation
Bulkheads partition your system so that failures in one area do not affect other areas. If your image processing service fails, bulkheads ensure that failure does not bring down your checkout service.
Bulkheads work closely with the fallback strategies from the previous section. When your database goes down and all requests hit your cache fallback, that cache becomes a single point of failure unless you have bulkheads in place. Without isolation, a cache overload takes down the service that was supposed to be your lifeline. With bulkheads, the cache gets its own thread pool, connection limits, and capacity — a failure there stays there.
Three patterns show up repeatedly in production systems:
- Thread pool isolation — give each downstream service its own thread pool. If the recommendation engine thread pool saturates, product searches still have threads available.
- Connection pool isolation — size connection pools per service based on that service’s SLA and importance. Your payment service might need 50 connections while your logging service gets 5.
- Process isolation — run non-critical services in separate processes with their own memory and CPU schedulers. A runaway image resizer cannot starve your checkout process.
Circuit breakers detect failures and stop calling unhealthy services. Bulkheads contain failures so they do not spread. Use both together: circuit breakers decide when to stop, bulkheads ensure that when you do fallback, the fallback has its own protected resources.
See the Bulkhead Pattern article for details on implementing bulkheads.
Fallback Strategies
When a service fails, you need something to return. The fallback strategy defines what that something is.
Cached Data Fallback
Cache recent responses from your services. When the service fails, return the cached response. The data might be stale, but it is better than an error.
def get_user_profile(user_id: str) -> UserProfile:
try:
profile = user_service.get(user_id)
cache.set(f"user_profile:{user_id}", profile, ttl=3600)
return profile
except ServiceError:
cached = cache.get(f"user_profile:{user_id}")
if cached:
logger.warning(f"Serving stale profile for {user_id}")
return cached
raise ProfileServiceUnavailable()
Set an appropriate TTL. Cache too long and you serve very stale data. Cache too short and you get no benefit during failures.
Default Value Fallback
For some data, you can return a sensible default when the service fails:
def get_recommendations(user_id: str, limit: int = 10) -> list[Product]:
try:
return recommendation_engine.get(user_id, limit=limit)
except ServiceError:
return product_service.get_popular(limit=limit)
def get personalized_price(user_id: str, product_id: str) -> Money:
try:
return pricing_service.get_personalized_price(user_id, product_id)
except ServiceError:
return product_service.get_price(product_id)
Static Content Fallback
Static content rarely fails. If your content delivery service fails, serve static fallback pages:
def get_homepage_content() -> HomepageContent:
try:
return cms_service.get_homepage()
except ServiceError:
return HomepageContent(
hero_title="Welcome to Our Store",
hero_subtitle="Shop our latest products",
featured_products=get_featured_products_cached(),
static_promotion=BASE_PROMOTION
)
Graceful Error Responses
When you cannot provide data, provide a graceful error. Do not return HTTP 500. Return HTTP 200 with an error indicator in the response body:
class ApiResponse:
def __init__(self, data=None, error=None, degraded=False):
self.data = data
self.error = error
self.degraded = degraded
@app.get("/api/product/{product_id}")
def get_product(product_id: str):
try:
product = product_service.get(product_id)
return ApiResponse(data=product)
except ProductNotFound:
return ApiResponse(error="Product not found"), 404
except ServiceError as e:
return ApiResponse(
data=get_product_basic(product_id),
error="Limited product information available",
degraded=True
), 200
Fallback Overload Protection
When your primary service fails, every request hits your fallback. If the fallback is not protected, you trade one failure for another. A common pattern: your database goes down, you fallback to cache, then your cache gets overwhelmed and goes down too.
Stack your fallbacks in layers:
import time
import threading
class TokenBucket:
def __init__(self, rate: float, capacity: int):
self.rate = rate
self.capacity = capacity
self.tokens = capacity
self.last_update = time.time()
self.lock = threading.Lock()
def consume(self, tokens: int = 1) -> bool:
with self.lock:
now = time.time()
elapsed = now - self.last_update
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
self.last_update = now
if self.tokens >= tokens:
self.tokens -= tokens
return True
return False
class ProtectedFallback:
def __init__(self, primary_fallback, secondary_fallback, static_fallback):
self.primary = primary_fallback
self.secondary = secondary_fallback
self.static = static_fallback
self.rate_limiter = TokenBucket(rate=100, capacity=50)
def get(self, key):
if self.rate_limiter.consume():
try:
return self.primary(key)
except Exception:
pass
try:
return self.secondary(key)
except Exception:
return self.static(key)
The first layer tries the primary fallback with rate limiting. If the rate limiter denies the request or the primary fails, you try the secondary. Only when both fail do you return static data. Each layer is simpler and harder to flood than the one above it.
Progressive Service Levels
Different users might get different service levels during degradation. Premium users get full functionality. Free users get degraded service.
def get_search_results(query: str, context: RequestContext):
results = search_service.search(query, limit=20)
if context.tier == "free":
if service_health.is_degraded("search"):
results = results[:5]
else:
pass
return results
This approach keeps your most valuable customers happy while protecting system resources.
Degradation Tier Methodology
Classify functionality into tiers to systematically plan degradation:
Tier Classification
| Tier | Description | Example | Availability Target |
|---|---|---|---|
| Tier 0 - Critical | Core business function; outage = revenue loss | Checkout, Payment processing | 99.99% |
| Tier 1 - Essential | Important but can tolerate brief outage | Product catalog, User authentication | 99.9% |
| Tier 2 - Enhanced | Improves experience but non-essential | Recommendations, Reviews | 99% |
| Tier 3 - Nice-to-Have | Full functionality extras | Social features, Personalized content | 95% |
Degradation Decision Matrix
| System State | Tier 0 Action | Tier 1 Action | Tier 2 Action | Tier 3 Action |
|---|---|---|---|---|
| Normal | Full service | Full service | Full service | Full service |
| Elevated load | Full service | Full service | Rate limited | Disabled |
| Partial outage | Full service | Degraded mode | Disabled | Disabled |
| Major outage | Degraded checkout | Disabled | Disabled | Disabled |
| Critical failure | Static fallback | Disabled | Disabled | Disabled |
Feature Classification Template
Classifying features is where most of the real work happens. The tier numbers are meaningless until you assign them consistently across your entire system. A misclassified feature creates hidden dependencies that surface at the worst possible moment.
Walk through each field with your team, not just engineering. The “User Impact” field especially benefits from product or customer success input. A recommendation engine that engineering calls Tier 2 might be the primary discovery mechanism for a user segment that never browses. That changes the impact assessment.
Tier assignment is the most consequential decision. Ask yourself: if this feature fails, does the user still accomplish their primary goal? If yes, Tier 2 or 3. If no, it is Tier 0 or 1. Social proof on a product page is Tier 2. The product page itself is Tier 0.
Dependency mapping is where hidden core functionality surfaces. List every service call the feature makes. If any of those services also appear in your checkout path, flag that dependency — it means the “non-essential” feature shares resources with something critical. Those are the dependencies that bite you.
Fallback definition must be concrete. “Some default” is not a fallback. “Return the top 10 most-popular products by category” is a fallback. The more specific the fallback, the fewer edge cases your operators have to handle during an incident at 2 AM.
Activation triggers should come from your monitoring. A good trigger ties to a metric: error rate above 5%, latency p99 above 2 seconds, or circuit breaker open for 30 seconds. Vague triggers like “when the system feels slow” lead to inconsistent decisions.
Use this template to document each feature:
## Feature: [Name]
- **Tier:** [0-3]
- **Dependency:** [What does it depend on?]
- **Fallback:** [What is returned when unavailable?]
- **User Impact:** [What does the user experience?]
- **Activation Trigger:** [When should this feature be degraded?]
Store completed classifications somewhere the whole team can find during incidents. A spreadsheet works. A shared wiki works. The format matters less than the act of writing it down — the classification conversation is more valuable than the document itself.
Automatic vs Manual Degradation
| Approach | Pros | Cons | When to Use |
|---|---|---|---|
| Automatic | Fast response, no human intervention | May misjudge severity | Known failure patterns |
| Manual | Human judgment on severity | Slower response | Ambiguous situations |
Use automatic degradation for clear patterns (circuit breaker open, high error rate). Use manual activation for nuanced decisions (regional degradation, partial failures).
Dependency Analysis
Graceful degradation requires knowing your dependencies. Map every service call and understand what happens when it fails.
graph TD
A[User Request] --> B[Product Service]
A --> C[User Service]
B --> D[Database]
C --> D
B --> E[Recommendation Engine]
C --> F[Cache]
E --> D
E --> G[ML Model Storage]
F --> D
B -.->|fallback| H[Return Cached Products]
C -.->|fallback| I[Return Default User]
When designing, draw this dependency graph for each major operation. For every arrow, ask: what happens if this fails? Can the operation continue with a fallback?
Health Checks and Degradation Signals
Health checks tell your load balancer which instances are healthy. But health checks can also signal when to activate degraded mode.
@app.get("/health")
def health():
checks = {
"database": check_database(),
"cache": check_cache(),
"recommendation_engine": check_recommendation_engine(),
"search": check_search(),
}
healthy = all(checks.values())
degraded = sum(checks.values()) >= len(checks) // 2
return {
"status": "degraded" if degraded else "healthy" if healthy else "unhealthy",
"checks": checks,
"degraded_mode": degraded
}
Your orchestration layer can read this health endpoint and make decisions. When the service reports degraded, route traffic differently. Serve cached content. Disable non-essential features.
See Health Checks for more on implementing health endpoints.
When to Use / When Not to Use Graceful Degradation
When to Use Graceful Degradation
Apply graceful degradation when there is a meaningful fallback — not just an empty void. Popular items instead of recommendations. Default pricing when personalization fails. A static hero image when the CMS is down. If the fallback gives users something productive, it is worth keeping the error path short.
Users need to be able to finish their primary task even when the enhanced feature is gone. A product page without reviews still shows the product. Checkout without social proof still completes. If the feature disappearing breaks the core task, it is not enhanced — it is core with a hidden dependency.
Third-party integrations are where this pays off most. Payment processors, shipping APIs, review aggregators — they fail on their own schedule, independent of anything you control. When these go down and you have a fallback, users never notice. When they go down and you do not, users see errors that are not your fault.
Long-lived sessions are the other good case. A user mid-checkout should not lose their cart because a recommendation engine is slow. A form save mid-entry should not reset because the autosave service timed out.
Do not use this pattern when no useful fallback exists. Returning nothing is often worse than returning an error. Do not use it for safety-critical or compliance-controlled paths — medical devices, financial transactions, aviation systems. Do not use it when the fallback logic would be more fragile than the primary path, because then you have added a new failure point instead of eliminating one.
Each fallback is code you need to test and maintain. Scope the approach to features where failure modes are predictable and the fallback behavior is simple enough to keep reliable.
Trade-off Comparison Tables
When implementing graceful degradation, you face several key trade-offs. These tables help you reason about the decisions:
Fallback Strategy Trade-offs
| Strategy | Availability Benefit | Consistency Cost | Complexity | Best For |
|---|---|---|---|---|
| Cached fallback | Immediate response | Stale data risk | Medium | Read-heavy services |
| Default value | Predictable response | Generic experience | Low | Display-oriented features |
| Static content | Zero failure surface | No personalization | Lowest | Marketing pages |
| Graceful errors | User-facing clarity | Requires UI support | Medium | API responses |
Degradation Approach Comparison
| Approach | Response Speed | Consistency | Operational Overhead | Risk of Misjudgment |
|---|---|---|---|---|
| Automatic | Seconds | Variable | Low | Wrong severity classification |
| Manual | Minutes to hours | Deliberate | High (requires staffing) | None |
| Hybrid | Fast with human override | Best of both | Medium | Complex coordination |
Degradation Tier Design Trade-offs
| Tier | User Experience | Operational Cost | Revenue Impact | Implementation Complexity |
|---|---|---|---|---|
| Tier 0 (Critical) | Full functionality | Highest (99.99% SLA) | No impact | Most complex |
| Tier 1 (Essential) | Core works | High (99.9% SLA) | Minimal | Complex |
| Tier 2 (Enhanced) | Reduced feature set | Medium (99% SLA) | Some impact | Moderate |
| Tier 3 (Nice-to-have) | Minimal functionality | Low (95% SLA) | Noticeable | Simple |
Feature Flag Trade-offs
| Flag Type | Activation Speed | Precision | Risk | Operational Complexity |
|---|---|---|---|---|
| Kill switch | Instant | Global only | High (all users) | Lowest |
| Percentage rollout | Minutes | Gradual | Low | Medium |
| User segment | Minutes | Targeted | Medium | Medium |
| A/B test | Hours | Statistical | Lowest | Highest |
Monitoring Degraded States
When your system enters degraded mode, you need to know. Set up alerting for degradation events.
def track_degraded_mode(service: str, fallback_used: str):
metrics.increment(
"degradation.events",
tags={"service": service, "fallback": fallback_used}
)
logger.warning(
f"Service {service} degraded, using fallback {fallback_used}"
)
Track these metrics:
- Degradation event rate per service
- Fallback activation frequency
- Stale data served percentage
- User-visible error rate during degradation
Combining with Other Patterns
Graceful degradation works best combined with other resilience patterns:
- Circuit breakers detect when to stop calling failing services
- Bulkheads isolate failures so they do not spread
- Retries attempt recovery before falling back
- Timeouts fail fast enough to enable fallback
For a broader view of these patterns, see Resilience Patterns.
Production Failure Scenarios
| Failure | Impact | Mitigation |
|---|---|---|
| Fallback returns stale data | Users see outdated content without knowing it | Include data freshness timestamps in responses; monitor staleness metrics |
| Fallback circuit itself fails | No fallback available when needed | Implement fallback fallbacks; circuit-break the fallback logic itself |
| Over-degradation | Too many features degraded simultaneously, system appears completely down | Define degradation tiers with clear thresholds; alert before all fallbacks activate |
| Fallback loop | Fallback service is called so much it also becomes overloaded | Add rate limiting to fallback paths; use separate bulkheaded resources for fallbacks |
| Silent failure | Degradation happens but users and operators don’t know | Log and metric every fallback activation; alert on fallback frequency spikes |
| Feature flag misconfiguration | Wrong tier of features degraded for wrong audience | Test feature flag configurations in staging; use canary deployments for flag changes |
| Cascading degradation | Fallback overload causes the primary to also fail | Bulkhead fallback resources; implement backpressure at the fallback boundary |
E-commerce Case Study
One retailer I read about implemented graceful degradation across their checkout flow. Their dependency graph looked like this:
- Cart service → Inventory service (check stock)
- Cart service → Pricing service (apply discounts)
- Checkout → Payment gateway
- Checkout → Fraud detection service
- Product page → Recommendation engine
- Product page → Review service
- Product page → Inventory (for stock counts)
During a 45-minute outage of the recommendation engine, they automatically degraded as follows:
- Tier 0 (core): Cart, Checkout, Payment — unaffected
- Tier 1 (important): Inventory stock counts, Pricing — served from cache where available
- Tier 2 (enhanced): Recommendations, Reviews — served static popular items, then empty
Conversion rate dropped 8% during the outage versus a predicted 40% without degradation. The recommendation engine outage was invisible to most users.
Degradation State Machine
Degradation is not a binary on/off switch. It moves through states, and each state has rules:
from enum import Enum
class DegradationState(Enum):
NORMAL = "normal"
ELEVATED = "elevated" # High load, some features rate-limited
DEGRADED = "degraded" # Partial outage, non-essential disabled
CRITICAL = "critical" # Major outage, only core available
RECOVERING = "recovering" # Coming back, gradual feature restoration
class DegradationStateMachine:
def __init__(self):
self.state = DegradationState.NORMAL
self.feature_tiers = {
"recommendations": 2,
"reviews": 2,
"inventory_details": 1,
"pricing": 1,
"checkout": 0,
"cart": 0,
}
self.active_features = set(self.feature_tiers.keys())
def should_activate(self, feature_tier: int) -> bool:
state_tiers = {
DegradationState.NORMAL: 3,
DegradationState.ELEVATED: 2,
DegradationState.DEGRADED: 1,
DegradationState.CRITICAL: 0,
DegradationState.RECOVERING: 2,
}
return self.feature_tiers.get(feature_tier, 3) <= state_tiers[self.state]
def transition(self, new_state: DegradationState):
old_state = self.state
self.state = new_state
logger.info(f"Degradation state: {old_state.value} -> {new_state.value}")
self._notify_observability(new_state)
def _notify_observability(self, state):
metrics.gauge("degradation.state", state.value)
def auto_transition(self, health_score: float, error_rate: float):
if self.state == DegradationState.NORMAL:
if error_rate > 0.05 or health_score < 0.8:
self.transition(DegradationState.ELEVATED)
elif self.state == DegradationState.ELEVATED:
if error_rate > 0.15 or health_score < 0.5:
self.transition(DegradationState.DEGRADED)
elif self.state == DegradationState.DEGRADED:
if error_rate > 0.30 or health_score < 0.3:
self.transition(DegradationState.CRITICAL)
elif self.state == DegradationState.CRITICAL:
if error_rate < 0.01 and health_score > 0.9:
self.transition(DegradationState.RECOVERING)
elif self.state == DegradationState.RECOVERING:
if error_rate > 0.05 or health_score < 0.7:
self.transition(DegradationState.DEGRADED)
elif error_rate < 0.005 and health_score > 0.95:
self.transition(DegradationState.NORMAL)
Transitions back from CRITICAL require sustained good health, not just a momentary improvement. Set your thresholds with real failure data when you have it.
For more on building resilient systems, see Resilience Patterns, Circuit Breaker Pattern, and Bulkhead Pattern.
Common Pitfalls / Anti-Patterns
Failing to Prioritize Core Functionality
The biggest mistake is not knowing what is core and what is enhanced. If your product page requires the recommendation engine to load, your recommendations are not enhanced. They are core functionality with a hidden dependency.
Map your dependencies. If A depends on B, then B is part of A’s core functionality.
This hides in subtle ways. A product page that calls the recommendation engine synchronously before rendering — the engine is not enhanced, it blocks the page load. When it goes down, the product page errors instead of showing products without recommendations.
Checkout flows have the same problem. If payment processing waits on a fraud detection service that can fail or add latency, that service is in your core path. You cannot degrade it independently.
Here’s the test: mock every external service to return an error and load the page. If the page still renders something useful, the failed services are enhanced. If it errors or shows nothing, those services are core with hidden dependencies.
Dependency mapping sessions surface these. Walk through every screen and ask — what happens if service X fails immediately? Complete failure means X is core. Rendered with degraded functionality means X is enhanced.
Returning Errors When You Could Fall Back
When the recommendation engine fails, do not show an error. Show popular items instead. When the social proof service fails, do not error. Show no social proof. When the personalization service fails, show the default experience.
Users rarely notice when extras disappear. They definitely notice errors.
Errors and fallbacks have asymmetric costs. An error gives the user nothing — they leave. A fallback gives them something useful — they complete their task. For enhanced functionality, the fallback almost always wins.
Common places this shows up:
- Inventory checks: service is down, product page errors instead of showing “availability unknown” with a checkout button that validates at charge time
- Pricing: personalization fails, price calculation throws instead of returning standard pricing
- Review counts: aggregation times out, product page errors instead of showing no count or a plain ” reviews” label
- Image transforms: CDN fails, image shows nothing instead of falling back to the original URL
In every case, the service fails, the code propagates the exception, the user sees blank space or an error. The fix: catch exceptions, apply fallbacks, log degradation events so you know how often this happens.
try:
recommendations = recommendation_engine.get(user_id, limit=5)
except ServiceError:
recommendations = get_popular_products(limit=5) # fallback
metrics.increment("recommendation_fallback_used")
The fallback does not need to be sophisticated. It just needs to be something. An empty state with a sensible message beats a blank screen or error banner every time.
Not Testing Degradation
You designed your system to degrade gracefully. But have you tested it? Most teams have not. They ship the code and hope it works when the failure actually happens. It usually does not.
Chaos engineering is the practice of injecting failures to see how your system actually responds. For degradation, you need to check three things: fallbacks fire when they should, the fallback does not also fail under load, and users see something useful instead of an error.
Start with the simplest test: disable each external service manually and load the page. Use feature flags, config changes, or network-level isolation. Check whether the product page loads without recommendations, the checkout completes without social proof, search works without personalization.
Then test under load. Primary fails, all traffic hits fallback — does the fallback survive? If your cache was never load-tested as a primary fallback, it will likely fold when it receives double or triple its normal traffic. Simulate this with load testing tools.
Then check your observability. Do metrics capture fallback activations? Do logs include degradation events? Do dashboards show when degradation mode is active? If you cannot measure degradation events, you cannot alert on them, and you will not know your system is running degraded.
See Chaos Engineering for techniques to inject failures and verify your system degrades as expected.
Overloading the Fallback
During a failure, your fallback might receive more load than normal. If your cache is the fallback for your database, and the database fails, all that load hits the cache. If the cache was not designed for that load, you lose both.
Design your fallbacks to handle the load they might receive during failures.
This is cascading failure in disguise. Primary fails, traffic shifts to fallback, fallback gets overwhelmed and fails too. Now you have no working path at all.
Fallbacks get designed for steady-state, not failure traffic. Your cache handles 5% of reads normally. When the database fails, it suddenly needs 100% of reads plus write-through. That is 20x normal load. Most caches are not configured for that.
When planning fallback capacity, assume the fallback needs 3x its normal traffic. Size accordingly. If your cache handles 1000 RPS normally and becomes the sole data source during a database outage, it needs to handle 3000 RPS or you need a secondary fallback that absorbs overflow.
Stack fallback layers so each tier is simpler and harder to overload than the one above it:
class FallbackChain:
def __init__(self):
self.tiers = [
RedisCache(), # Fast, in-memory, limited capacity
MemcachedPool(), # More capacity, slower
StaticDataStore(), # Unlimited capacity, static data
]
def get(self, key):
for i, tier in enumerate(self.tiers):
try:
return tier.get(key)
except FallbackExhausted:
if i == len(self.tiers) - 1:
raise
continue # try next tier
Rate limiters protect the chain. A token bucket or semaphore on the primary fallback path keeps a thundering herd from overwhelming your fallback in the first seconds of a failure.
Bulkhead fallback resources too. Give your fallback cache its own memory allocation, network interface, connection pool. If the fallback shares resources with production, fallback load can starve the primary service trying to recover.
Quick Recap
Graceful degradation keeps your system useful when parts fail. Design it in from the start:
- Know what is core functionality and what is enhanced
- Implement fallbacks for every external service call
- Test your degradation paths with chaos engineering
- Monitor when degradation activates
- Combine with circuit breakers, bulkheads, and retries
The goal is not perfection. The goal is survival. A system that degrades gracefully is worth more than a system that fails catastrophically.
Interview Questions
Expected answer points:
- Graceful degradation: system continues operating at reduced functionality
- Graceful failure: system stops gracefully but completely, often after exhausting fallbacks
- Degradation implies partial operation; failure implies complete unavailability with clean shutdown
Expected answer points:
- Circuit breakers detect when a downstream service is failing
- They stop requests to the failing service, preventing cascade failures
- Once open, they return errors immediately rather than timing out
- Combined with degradation, circuit breakers trigger fallback activation
Expected answer points:
- NORMAL: all features operating at full capacity
- ELEVATED: high load, some non-essential features rate-limited
- DEGRADED: partial outage, Tier 2+ features disabled
- CRITICAL: major outage, only Tier 0 (core) features available
- RECOVERING: transitioning back to normal, gradual feature restoration
Expected answer points:
- If the primary database fails, all requests hit the cache fallback
- If cache was not bulkheaded, it gets overwhelmed and fails too
- Separate bulkheads prevent cascading degradation from fallback overload
- Each fallback tier should have independent capacity and isolation
Expected answer points:
- Rate limiting: controls how many requests can pass over time (throttling)
- Load shedding: completely drops requests under extreme load
- Feature flags implement load shedding by disabling entire feature categories
- Rate limiting can apply to both primary and fallback paths
Expected answer points:
- Longer TTL = more staleness risk but better availability during extended outages
- Shorter TTL = fresher data but less benefit when failures occur
- Consider business tolerance for stale data vs. outage duration
- Include freshness timestamps in responses so clients can detect staleness
Expected answer points:
- Fallback loop: fallback service receives so much load it also fails
- Prevention: add rate limiting to fallback paths
- Use separate bulkheaded resources for fallbacks
- Stack fallbacks in layers (primary → secondary → static) with increasing capacity
Expected answer points:
- Ambiguous situations where automated systems might misjudge severity
- Regional failures where global auto-degradation would be inappropriate
- Partial failures affecting specific customers or data sets
- Situations requiring business context beyond metrics
Expected answer points:
- Tier 0: core business function; outage directly means revenue loss
- Tier 1: important but can tolerate brief outage; recovery within minutes
- Tier 2: enhances experience but users still accomplish primary goal without it
- Tier 3: nice-to-have extras; users barely notice their absence
Expected answer points:
- Degradation event rate per service (how often degradation activates)
- Fallback activation frequency (which fallbacks are being used)
- Stale data served percentage (how much outdated content users see)
- User-visible error rate during degradation (are users actually affected)
Expected answer points:
- Premium users get full functionality; free users get degraded service
- Protects revenue by keeping most valuable customers happy
- Allows controlled resource allocation during stress
- Example: free tier search returns 5 results vs 20 for premium during degradation
Expected answer points:
- HTTP 500 indicates server error, not a controlled degraded state
- Better to return HTTP 200 with degraded=true flag in response body
- Clients can gracefully handle the degraded indicator
- Users see partial content rather than error pages
Expected answer points:
- Short timeouts fail fast enough to activate fallbacks before user waits
- Long timeouts let failures cascade while waiting for unresponsive services
- Timeout + fallback = fast failure + useful response instead of slow error
- Timeouts should be tuned per downstream service based on its SLA
Expected answer points:
- Chaos engineering: inject failures to verify degradation paths work
- Game days: simulate major outages and observe system behavior
- Feature flag testing: manually activate degradation modes in staging
- Circuit breaker testing: force circuit open and verify fallback fires
Expected answer points:
- Graceful degradation: partial operation continues, users get reduced functionality
- Disaster recovery: complete failover to backup systems or static pages
- Degradation is for survivable failures; disaster recovery for catastrophic ones
- Degradation assumes fallback exists; disaster recovery assumes no fallback
Expected answer points:
- Health endpoints signal when instances are degraded to load balancers
- Degraded health status triggers routing away from unhealthy instances
- Orchestration layers read health endpoints to make degradation decisions
- Health checks can include degradation-specific signals beyond simple alive/dead
Expected answer points:
- Too many features degraded simultaneously makes system appear completely down
- Users cannot distinguish between "everything is broken" and "degraded mode"
- Define clear degradation tiers with thresholds before all fallbacks activate
- Alert before all fallbacks activate to catch degradation cascade early
Expected answer points:
- Silent failure: degradation happens but nobody knows it happened
- Users see reduced functionality without understanding why
- Operators don't see alerts, so they don't know to investigate
- Prevention: log and metric every fallback activation; alert on fallback frequency spikes
Expected answer points:
- Wrong tier degraded for wrong audience (e.g., premium users get degraded, free users don't)
- Prevention: test feature flag configurations in staging before production
- Use canary deployments for flag changes to catch issues early
- Have rollback mechanisms for feature flag changes
Expected answer points:
- Degradation occurs but neither users nor operators are aware
- Users see reduced functionality without explanation
- Operators don't receive alerts, so they cannot investigate or respond
- Prevention: log every fallback activation, alert on fallback frequency spikes, include degradation indicators in responses
Further Reading
For more on building resilient systems, see Resilience Patterns, Circuit Breaker Pattern, and Bulkhead Pattern.
Conclusion
Graceful degradation assumes you can still serve something useful. Sometimes failures are too severe. Sometimes there is no fallback that makes sense.
When graceful degradation is not enough, you need Disaster Recovery planning. Know when to failover completely. Know when to show a maintenance page. Know when to redirect traffic.
Category
Related Posts
The Eight Fallacies of Distributed Computing
Explore the classic assumptions developers make about networked systems that lead to failures. Learn how to avoid these pitfalls in distributed architecture.
Health Checks in Distributed Systems: Beyond Liveness
Explore advanced health check patterns for distributed systems including deep checks, aggregation, distributed health tracking, and health protocols.
Bulkhead Pattern: Isolate Failures Before They Spread
The Bulkhead pattern prevents resource exhaustion by isolating workloads. Learn to implement bulkheads, partition resources, and use them with circuit breakers.