3:00 a.m.
The backend wasn't slow.
It was gone.
Thousands of users, one blank page. No errors. No retries. Nothing, the kind of silence that's worse than a stack trace, because there's nothing to grab onto.
And I'd already lived this once.
Back in 2013, on the launch night of GTA V, the most anticipated release of the year. I was on a team running a game store on Magento with the frontend, the backend, and the database all on a single server. The midnight rush buried it. The box went down and stayed down for a little over two hours: gamers who'd counted down for weeks meeting a dead page on the one night that mattered, and a team scrambling to bring the server back to life. We had no degraded mode. We had nothing to show at all.
The lesson that stuck with me isn't "make the backend never fail." You can't. The lesson is: availability is not binary. A mature system doesn't flip between "perfect" and "dead." It degrades. A button that fails is a bug. An app that vanishes is a catastrophe and they are very different engineering problems.
The tenet: degrade, don't die
Most outages aren't total. A queue backs up, one dependency times out, the primary database fails over, a third-party API returns 503. In every one of those cases there is still a version of your product that is useful, if you designed for it.
Same failure, the API goes down, two very different outcomes:
WITHOUT graceful degradation WITH graceful degradation
[ Frontend ] [ Frontend ]
│ │
▼ ▼
[ API ] ✗ offline [ Cache, stale data ] ← API down? fall back
│ │
▼ ▼
✗ the whole product dies ✓ the user keeps browsing
The goal of this article is to make that "useful degraded version" a deliberate architectural decision instead of an accident you discover in production.
The foundation: decouple the frontend from the backend's fate
Before any pattern, one structural choice does most of the heavy lifting: the frontend should be able to boot and render without the backend being alive.
That means a static, CDN-served frontend (always up, no origin to fall over) decoupled from a managed backend + database that is allowed to degrade:
[ Browser ]
│
▼
[ CDN / static frontend ] ← always available, renders the shell + cached data
│ (async)
▼
[ API + DB ] ← may be slow, read-only, or down, the UI survives it
If your frontend can't paint a meaningful screen when the API is unreachable, no resilience pattern downstream will save you. This is the single highest-leverage decision, and everything below builds on it.
Six patterns, each tied to a failure it solves
1. Serve the last known good data (stale-while-revalidate)
Failure it solves: the API is down or timing out on a read.
Returning a cached-but-stale response beats returning an error almost every time. The user sees something real while you revalidate in the background.
async function getWithFallback<T>(key: string, fetcher: () => Promise<T>) {
try {
const fresh = await withTimeout(fetcher(), 800); // fail fast, see pattern 3
await cache.set(key, fresh);
return { data: fresh, stale: false };
} catch {
const cached = await cache.get<T>(key);
if (cached) return { data: cached, stale: true }; // degrade, don't die
throw new ServiceUnavailable(key);
}
}Surface the stale flag in the UI honestly ("Showing data from a few minutes
ago"), see pattern 5.
2. Read-only / degraded mode
Failure it solves: writes become dangerous or too expensive, a primary failing over, or write contention buckling under a traffic spike, while reads can still be served, from a replica or even just cache.
Most apps are read-heavy. When writes become dangerous, disabling writes while keeping reads alive turns a total outage into a minor inconvenience.
if (systemMode === "read-only") {
// reads pass through (replica or cache); writes queue or reject clearly
return res
.status(202)
.json({ queued: true, message: "Saved, will sync shortly" });
}This is exactly what saved us after that GTA V outage. In the post-mortem, the
team's decision wasn't "buy a bigger server", it was "never have nothing to show
again." The real bottleneck was MySQL write contention: every cart and order
hammered the quote and order tables (sales_flat_quote and friends). So we built
a load-triggered read-only mode. When traffic spiked, the "Add to cart" button was
swapped for a lightweight static form that captured just an email and the game ID,
no heavy write, while the catalog kept rendering from cache. The store stayed
browsable and useful under the exact load that used to take it completely
offline. Checkout was degraded, but the product never disappeared again.
3. Fail fast: timeouts and circuit breakers
Failure it solves: a slow dependency dragging the whole request chain down, the silent killer worse than an outright failure.
A dependency with no timeout doesn't fail; it hangs, holding your threads and connections hostage until everything collapses. Every network call needs an aggressive timeout, and repeated failures should open a circuit so you stop hammering a dead service and return a fallback instantly.
const breaker = new CircuitBreaker(callPricingApi, {
timeout: 800, // give up fast
errorThresholdPercentage: 50,
resetTimeout: 10_000, // probe again after 10s
});
breaker.fallback(() => getCachedPrice()); // instant degraded answerThe circuit breaker is what turns "one slow dependency" from an outage into a footnote.
4. Isolate failures: bulkheads
Failure it solves: one struggling feature exhausting a shared resource and taking down unrelated features with it.
Name comes from ships: watertight compartments so one breach doesn't sink the whole hull. Give each dependency its own connection pool / concurrency limit so the recommendations service drowning can't starve checkout of database connections.
5. An honest UI: skeletons, clear errors, queued actions
Failure it solves: the user's trust, which is what you actually lose in an outage.
Degradation is only graceful if the user understands what's happening:
- Skeletons, not spinners, while data loads.
- Scoped error states, one broken widget shows an inline "couldn't load this," it doesn't blank the page.
- Optimistic + queued writes, accept the action, show it as done, sync when the backend returns ("Saved, will sync shortly").
Silent failure and total failure both destroy trust. Honest degradation keeps it.
6. Feature flags as a load-shedding valve
Failure it solves: the system is overwhelmed and you need to buy headroom now.
Flags let you turn off the expensive, non-essential feature (the heavy personalized feed, the real-time recommendations) in seconds, no deploy and keep the core flow alive under pressure. Shedding load is a feature, not a defeat.
The trade-offs (because this isn't free)
Senior work is naming the cost, not just the win:
- Complexity. Every fallback is a second code path to build, test, and reason about. Don't add graceful degradation to a screen nobody would miss for 30 seconds.
- Stale data. Serving cached data means serving wrong data sometimes. Fine for a product catalog; unacceptable for an account balance. Choose per data type.
- False confidence. Fallbacks that are never exercised rot. If you don't test degraded mode, ideally with fault injection in staging, assume it's broken.
The rule of thumb: apply this to the paths that define your product's core value, and let the periphery fail loudly.
The part nobody connects: this makes your infrastructure cheaper
Here's the angle I care about most. Teams that can't degrade are forced to over-provision, they buy capacity for the absolute worst-case spike "because otherwise it falls over." That headroom is pure cost, burning 24/7 for an event that happens twice a year.
A system that degrades gracefully changes the economics. It can shed load instead of scaling to meet every peak. It can ride out a dependency failure on cache instead of a hot standby of everything. Resilience and cost-efficiency aren't a trade-off here, graceful degradation buys you both. You stop paying a permanent premium to avoid a failure mode you could have designed around.
When our store went dark on GTA V night, the obvious fix on the table was "buy a much bigger server so this never happens again", pay permanently for peak capacity to survive one night a year. We took the other path: we made the system degrade. The store then stayed browsable under the same load that used to kill it, on the same modest hardware.
And the loss we were insuring against? We could never even measure it. The sales team couldn't put a number on those two hours, once a customer hits a blank page there's no report that tells you who waited for us to come back and who just bought the disc somewhere else. That unmeasurable loss is exactly what over-provisioning tries to buy off, expensively. Graceful degradation bought it off cheaply and kept us open instead of dark.
Does your system degrade, or die?
A checklist to run against your own architecture:
- Can the frontend render a useful screen with the API completely down?
- Does every outbound network call have an aggressive timeout?
- Do repeated failures open a circuit and return a fallback, instantly?
- Is there a read-only mode for when writes become unsafe?
- Does a single broken widget stay contained, or blank the whole page?
- Can you shed a heavy nonessential feature in seconds, without a deploy?
- When did you last test the degraded path?
If you answered "no" to more than two, you don't have a resilient system, you have one that hasn't failed yet.