Graceful Degradation: Building Systems That Bend, Not Break
By Sergey Nosov
16 October 2026
Around 2001, Amazon’s website began showing shipping speeds for every product. The supply chain database behind the feature could not keep up with the site’s traffic, so each web server got a cache, with a fallback to the database if the cache failed. Months later, as Jacob Gabrielson tells it in an article for the Amazon Builders’ Library, the caches failed at about the same time. Every web server went to the database. It locked up, taking down the website and Amazon’s fulfillment centers worldwide, which depended on it too.
In Gabrielson’s words, the fallback “turned a partial website outage (not being able to display shipping speeds) into a full-site outage (no pages loaded at all).” When a part fails, a system can break, make things worse by chasing the same result another way, or do less and keep its core working. Graceful degradation is the discipline of choosing the last in advance: deciding what the system does without each of its parts, and making sure the reduced answer is honest about what is missing.
The sections below show how it fits with failing fast and how to classify dependencies, then give four patterns in JavaScript. Three more cover what goes wrong: fallbacks that make an outage worse, reduced answers that mislead, and degraded paths that fail the first time they are needed.
What Graceful Degradation Means
The principle fits in one sentence: a system should keep providing its core functionality when noncritical components fail, rather than breaking entirely. AWS’s Well-Architected Framework lists it as a reliability best practice and says components that lose a dependency “might be serving slightly stale data, alternate data, or even no data.” Google’s book Site Reliability Engineering applies the idea to overload: a search service might look only at the data it caches in memory, not the full database.
Suppose a store’s recommendation service starts timing out. A brittle product page waits for it, gets an error, and shows an error page: one optional box has broken the whole page. The root cause is treating every dependency as equally critical. A degrading page catches the error, logs it, and leaves out the “Recommended for you” box, so customers can still buy. AWS uses nearly the same example: a store’s landing page should show what its other systems provide, not an error page, when one fails.
Front-end developers use the term more narrowly: MDN defines it as building a site for the newest browsers, with a fallback for older ones. Here, the missing piece is a dependency that fails, slows down, or sends garbage while the system is running.
Fail Fast and Degrade Gracefully
Graceful degradation is often contrasted with fail-fast design, and at first glance the two look contradictory: one keeps going, and the other stops. In practice they complement each other, and both belong in production.
Jim Shore’s article “Fail Fast,” published in IEEE Software in 2004, defines the idea: “when a problem occurs, it fails immediately and visibly.” He does not confine failing fast to development. To disabling assertions in released software, he says, “Don’t do that!” He recommends a global error handler instead: a batch system, for example, might page an administrator and go on to the next transaction. The transaction that failed stops, and the system carries on.
That is the shape of the whole relationship. A component fails fast: it reports a problem at once and stops work on a request it cannot complete correctly. Its caller degrades: it decides what the response can still offer without that component. A dependency that fails fast gives its caller the chance to degrade; one that hangs can take the caller down with it. Google’s SRE book gives callers the matching advice: “If your RPC layer supports a fail-fast option, use it.”
The caller still has to choose between degrading and failing. As Ben Schmaus wrote in December 2011, Netflix’s API team made that choice per dependency. A custom fallback built a substitute response, for example from a local cache. “Fail silent” returned null for optional data. “Fail fast,” for data that was required or had no good fallback, sent the client a 5xx response.
What decides between degrading and failing is the cost of being wrong. Degrade when a missing part makes the answer smaller or older; fail when continuing would make the answer wrong. Authorization is the clearest case for failing. A check that decides who may act should refuse the request whenever it cannot answer, whether it errors or times out. Failing open hands a bypass to anyone who can make the check fail, as my article on the CAP theorem argues.
The entry on graceful degradation in my Software Development Principles series frames the choice by context: financial transactions may demand failing fast, while content delivery benefits from degrading. AWS puts it as a trade-off between consistency and availability when both cannot be kept. The one to keep might be consistency for payment processing and availability for a real-time application; for a customer-facing website, it may depend on what customers expect.
Classify Every Dependency
Graceful degradation starts on paper. List what each request depends on, and sort the dependencies by what their absence costs. The book entry uses three tiers:
- Critical: the response is meaningless without it, such as the product itself, authentication, or payment.
- Enhancement: the response is better with it, such as reviews, recommendations, or a delivery estimate.
- Optional: convenience or telemetry, such as analytics events or a chat widget.
Three rules make the classification useful.
- The tier belongs to the use, not to the service. Stock levels are an enhancement on a product page, which can say “Check availability at checkout.” At checkout they are critical, because selling what you do not have costs real money. The same service gets two entries.
- Not all logging is optional. AWS says it is often best to carry on when a component intermittently cannot reach its monitoring service. But “silently not logging or pushing metrics for a long time is often not acceptable,” it adds, and some uses need complete audit entries for compliance. So operational logs stay optional, with an alert when they stop, while a required audit entry is part of the operation it records.
- The decision is not only technical. AWS calls it “as much a business discussion as a technical one.” Whether a stale price, a missing delivery date, or an error page costs more is a question for the people who own the business outcome.
For each dependency, write down four answers: its tier, its time budget, its fallback, and the signal that tells operators it has degraded.
Four Patterns in JavaScript
The samples run on Node.js 24 and need no packages: fetch, AbortSignal, and the node:test runner are built in. I tested them against a local stub API that answers, fails, delays, or hangs on request. In the samples, both lower tiers count as optional calls: the response can go out without them.
Catch the Failure Where It Happens
The smallest step keeps an optional call from failing the whole request. Here is the brittle version:
// Brittle: a recommendations error
// fails the whole page.
const recs = await getRecommendations(userId);
renderRecommendations(recs);
And the degrading one:
// Graceful: the page renders, and
// only the section is missing.
try {
renderRecommendations(await getRecommendations(userId));
} catch (err) {
console.warn(`recommendations unavailable: ${err.message}`);
hideRecommendations();
}
Two details matter. The catch block logs, so the failure stays visible. And the fallback is local: it hides the section instead of calling another service. At Amazon, a fallback that called another system took the site down.
Load the Optional Parts in Parallel
A real page needs several things at once. For the optional ones, Promise.allSettled is the right tool: it waits for all of them and reports each outcome, where Promise.all would reject at the first failure. The critical call is awaited separately, with no fallback:
const API = process.env.API_BASE ?? "http://localhost:8080";
async function getJson(path, signal) {
const res = await fetch(new URL(path, API), { signal });
if (!res.ok) {
// Free the connection first.
await res.body?.cancel();
throw new Error(`GET ${path}: HTTP ${res.status}`);
}
return res.json();
}
// The last good reviews per
// product, for when a call fails.
const lastReviews = new Map();
async function getReviews(id, signal) {
const reviews = await getJson(`/reviews/${id}`, signal);
// Valid JSON is not valid data:
// cache only a list.
if (!Array.isArray(reviews)) {
throw new Error(`GET /reviews/${id}: not a list`);
}
lastReviews.set(id, reviews);
return reviews;
}
export async function loadProductPage(productId, userId) {
const page = new AbortController();
const within = (ms) =>
AbortSignal.any([page.signal, AbortSignal.timeout(ms)]);
// Start the optional calls first
// to overlap the critical one.
const optional = Promise.allSettled([
getReviews(productId, within(300)),
getJson(`/recommendations/${userId}`, within(300)),
getJson(`/stock/${productId}`, within(200)),
]);
let product;
try {
// Critical: no fallback.
product = await getJson(`/products/${productId}`,
AbortSignal.timeout(1000));
} catch (err) {
// Stop the optional calls too.
page.abort();
throw err;
}
const [reviews, recs, stock] = await optional;
const degraded = [];
const settle = (name, result, fallback) => {
if (result.status === "fulfilled") return result.value;
degraded.push(name);
console.warn(`${name} degraded: ${result.reason.message}`);
return fallback();
};
return {
product,
reviews: settle("reviews", reviews,
() => lastReviews.get(productId) ?? null),
recommendations: settle("recommendations", recs, () => null),
stock: settle("stock", stock, () => ({ known: false })),
degraded,
};
}
Five decisions are built into this function:
- The optional calls start first. They run while the critical call is in flight, so the page waits for its slowest call, not for the critical call plus the slowest optional one. The price is wasted work: a request for a product that does not exist still costs three optional calls.
- Each optional call has its own time budget.
within(300)combines the page’s abort signal withAbortSignal.timeout(300)throughAbortSignal.any, and the request stops at whichever fires first. - The critical call has no fallback. If the product cannot load, the page fails, on purpose. Before it rethrows, the function aborts the optional calls, so a failed page leaves none of its requests open, although a service may finish work it has already started.
- Each fallback says what it knows. Reviews fall back to the last good copy, or to
nullwhen there is none; an empty list would claim that the product has no reviews. Stock falls back to{ known: false }rather than to a stale count. Recommendations fall back tonull, and the page leaves the box out. - The result lists what degraded. The
degradedarray lets the page tell users about a gap that matters to them, and lets the server count degraded responses per part.
In one test, reviews failed with HTTP 500, recommendations never answered, and stock took 500 milliseconds. The function returned in about 310 milliseconds with the last good reviews, no recommendations, and stock unknown. The stub saw the stock connection close after about 200 milliseconds and the recommendations connection after about 300. When I made the product call fail instead, the function threw after 62 milliseconds, and the two optional requests still in flight closed at the same moment.
A few smaller details matter. On an error status, getJson cancels the response body before it throws. That matters most during an outage, when error responses pile up: the library behind the fetch in Node.js, undici, warns that bodies left to the garbage collector can cause “stalls or deadlocks when running out of connections.”
Before caching, getReviews checks that it got a list: valid JSON is not valid data, and a wrong-shaped answer would otherwise become the fallback. In production, check every response before you use it, and give the lastReviews map a size limit and a maximum age. The samples also assume that IDs were validated where the request came in. An unchecked ID such as .. changes which URL getJson requests, and encodeURIComponent does not stop it, because it leaves dots alone.
Bound Every Wait
A dependency that fails quickly is easy to handle. A slow one is worse. Netflix’s API team called latency “the worst-case type of failure for a subsystem.” At high volume, one slow dependency could tie up every request thread on a server and bring the whole API down.
Do not count on the default. The documentation for undici gives a default of 300 seconds to receive response headers. In my test, a fetch to a server that accepted the connection and never answered failed after 306 seconds, with a headers timeout. For a page, that is the same as no answer at all.
A common way to add a timeout in JavaScript is a Promise.race between the request and a timer. It stops the waiting, not the work:
// Stops the waiting, not the work:
// the request runs on, and so does
// the timer.
function withTimeout(promise, ms) {
const timer = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), ms));
return Promise.race([promise, timer]);
}
await withTimeout(fetch(url), 300);
// Stops the request itself.
await fetch(url, { signal: AbortSignal.timeout(300) });
Against a stub that took 2 seconds to answer, the race version gave up after about 300 milliseconds, but the request ran on. The stub still sent its full response 2 seconds after the request arrived. With AbortSignal.timeout(300), fetch rejected with a TimeoutError after 307 milliseconds, and the stub saw the client close the connection.
The race version has a second cost: its timer keeps running after the race is decided, and in Node.js a pending timer keeps the process alive. A script that finished 50 milliseconds of work stayed alive for the full 3 seconds of its timeout. The timer that AbortSignal.timeout starts is not cleared early either, but Node.js does not let it hold the process open. The same script with AbortSignal.timeout(3000) in place of the race exited after 66 milliseconds.
When a request crosses several services, a budget per call is not enough. The SRE book’s chapter on cascading failures recommends deadline propagation: set one absolute deadline high in the stack, and pass the remaining time along with every downstream call. As the chapter puts it, “you don’t get credit for late assignments with RPCs.” The page signal in loadProductPage does a related job, which the chapter calls cancellation propagation: when the critical call fails, the page’s other calls stop too.
Switch Features Off, and Let Them Come Back
Some degradation is a decision rather than a reaction. Pete Hodgson’s article “Feature Toggles (aka Feature Flags)” describes long-lived kill switches that let operators turn off nonvital features under unusually high load. A switch that takes a release to flip is too slow. Hodgson puts it mildly: “needing to roll out a new release in order to flip an Ops Toggle is unlikely to make an Operations person happy.” A flags object written into the code needs a release, and a setting read only at startup needs a restart.
So the flags come from a source that operators can change at run time. The client below loads them when it starts and every 30 seconds after, and the code reads the current copy on every request:
const FLAGS_URL = process.env.FLAGS_URL
?? "http://localhost:8080/flags";
// Shipped defaults, in effect
// until a refresh succeeds.
let flags = { recommendations: true, reviews: true };
async function refreshFlags() {
try {
const res = await fetch(FLAGS_URL, {
signal: AbortSignal.timeout(2000),
});
if (!res.ok) {
await res.body?.cancel();
throw new Error(`HTTP ${res.status}`);
}
const next = await res.json();
const isRecord = typeof next === "object"
&& next !== null && !Array.isArray(next);
if (!isRecord) throw new Error("not a JSON object");
// Known names only. One bad
// value rejects the update.
const update = {};
for (const name of Object.keys(flags)) {
if (!Object.hasOwn(next, name)) continue;
if (typeof next[name] !== "boolean") {
throw new Error(`invalid flag: ${name}`);
}
update[name] = next[name];
}
flags = { ...flags, ...update };
} catch (err) {
// Keep the last good flags.
console.warn(`flag refresh failed: ${err.message}`);
}
}
await refreshFlags();
setInterval(refreshFlags, 30_000).unref();
Each refresh has a 2-second budget. The first runs before the module finishes loading, so a server that starts during an incident waits for the current kill switches before its first request. If that refresh fails, the server starts with the shipped defaults. A bad update changes nothing: when the response is not a JSON object, or a known flag is not Boolean, the client keeps the last good flags.
Names the client does not know are skipped, so a flag added for newer code cannot freeze the kill switches on older servers. A misspelled kill switch is skipped too, silently, so the flag service should reject any name it has no definition for.
A kill switch needs a person to flip it. A circuit breaker flips itself. Michael Nygard popularized the pattern in his book Release It! In Martin Fowler’s short description, once failures reach a threshold, the breaker trips and fails every further call immediately, without calling the protected service. After a pause, a half-open state lets a trial call through to test whether the problem is fixed.
A tempting shortcut is a flag that the code switches off at the first error. It looks like a breaker but behaves differently: one blip disables the feature for the life of the process, on that one server, and nothing turns it back on. The book entry lists the opposite among its signs of proper application: “Recovery from degraded mode to full operation occurs automatically when dependencies return.” Here is a minimal breaker that recovers by itself:
class CircuitOpenError extends Error {}
class Breaker {
#failures = 0;
#openUntil = 0;
#trialRunning = false;
constructor({
threshold = 5,
coolDownMs = 30_000,
} = {}) {
this.threshold = threshold;
this.coolDownMs = coolDownMs;
}
async call(fn) {
let trial = false;
if (this.#failures >= this.threshold) {
// Open: fail fast, no call.
if (this.#trialRunning || Date.now() < this.#openUntil) {
throw new CircuitOpenError("circuit open");
}
// Half-open: one trial call.
trial = this.#trialRunning = true;
}
try {
const value = await fn();
this.#failures = 0;
return value;
} catch (err) {
// The caller gave up: not a
// failure of the service.
if (err.name === "AbortError") throw err;
this.#failures += 1;
if (this.#failures >= this.threshold) {
this.#openUntil = Date.now() + this.coolDownMs;
}
throw err;
} finally {
if (trial) this.#trialRunning = false;
}
}
}
The open state is failing fast in its purest form: the caller gets an error at once, without a network call, and degrades. The struggling service benefits too, because an open circuit sends it no load. The breaker relies on each call’s own time budget, though: a trial call that never settled would hold the circuit open.
Both controls plug into the slot the product page already has. In loadProductPage, call getRecommendations(userId, within(300)) in place of the direct request for recommendations:
const recsBreaker = new Breaker({
threshold: 5,
coolDownMs: 30_000,
});
function getRecommendations(userId, signal) {
// Off is a decision, not a
// failure: no call, no warning.
if (!flags.recommendations) return Promise.resolve(null);
return recsBreaker.call(() =>
getJson(`/recommendations/${userId}`, signal));
}
When an operator switches recommendations off, the page sends no request and logs no warning, because a switched-off feature is a decision, not a failure. Still count the pages served with the feature off, separately from degraded ones, so a switch left off by mistake shows on a dashboard.
When the service fails, the page degrades as before, and after five failures in a row the breaker stops calling it for 30 seconds. In my test, the sixth page load sent no request to the failing service, and the first load after the cool-down sent exactly one. Once the service recovered, a single successful trial closed the circuit.
The breaker does not count the caller’s own abort as a failure. The page aborts without a reason, so its calls reject with an AbortError; a timeout from AbortSignal.timeout rejects with a TimeoutError and still counts. Without the AbortError check, five failed product pages in my test aborted five healthy recommendation calls and opened the circuit. The sample counts every other error, but a 404 or a validation error is an answer, not an outage, so a production breaker should count only timeouts, connection errors, and server errors.
Fowler adds that a production breaker should log every change of state and let operators trip or reset it by hand. A maintained library saves writing all of that. For Node.js, opossum reached version 10.0.0 in June 2026 and supports Node.js 22, 24, and 26. For Java, Netflix now points new projects to Resilience4j, because its own Hystrix library is in maintenance mode.
Degrade by Doing Less, Not Something Riskier
Instead of doing less, Amazon chased the same result another way, which Gabrielson’s article calls fallback: using “a different mechanism to achieve the same result.” The article explains why the company almost never uses one and judges that the shipping-speed fallback “was worse than no fallback strategy at all.”
Netflix’s engineers drew a finer line. In February 2012, Ben Christensen wrote that a fallback runs on the calling thread and “should not perform network calls.” In Hystrix’s documentation, a fallback that needs the network, such as a remote cache, gets the same protection as the call it replaces: its own command, thread pool, and fallback.
Schmaus’s 2011 post shows the safe case. To work around an API bug that put incomplete objects into a shared cache, the team sent reads straight to the database, which throttled the API. The circuit breaker switched to a fallback that checked a local query cache holding most of the active data. Schmaus reports that the impact on members was very low. During the incident, 157 of 200 servers were serving fallbacks at once.
The rule I take from these stories: a fallback should use data the caller already holds, or do less work. Fallbacks fire when something is already failing, often on many servers together, as both stories show. So a fallback that calls another system needs its own budget, and that system must survive every server calling it at once, which Amazon’s database could not. AWS’s guidance points the same way: the paths a system takes when a component fails must be tested and should be much simpler than its main path.
Make the Reduced Answer Honest
On 18 November 2025, an oversized configuration file for Cloudflare’s Bot Management module reached every machine in its network. My article on the Boundary Defense Principle argues that the proxy should have rejected the file; the question here is what it should have served without it. The newer of the company’s two proxy versions returned HTTP 5xx errors. The older one kept serving but gave every request a bot score of zero, so customers with rules to block bots, the postmortem says, “would have seen large numbers of false positives.”
Cloudflare’s documentation calls a score of 0 “not computed,” which “does not indicate the request is safe or human.” But computed scores run from 1 to 99, and low scores point to bots, so a rule that blocks low scores can catch zero too. One of the documentation’s example rules does: it blocks every request that scores below 30 and is not a verified bot. By the test from the fail-fast section, no score is a smaller answer, and a zero read as a low score is a wrong one.
Cloudflare’s fixes choose the older or the smaller answer. After another outage, on 5 December 2025, the company promised that a corrupt or out-of-range configuration file would make the system “log the error and default to a known-good state or pass traffic without scoring, rather than dropping requests.” Its report on finishing that work, published on 1 May 2026, puts the last known good configuration first (“fail stale”). Failing that, the bot module would now fail open: serving traffic is, in the report’s words, “a much better outcome than downtime.”
A fallback must be distinguishable from real data by the code that consumes it: null rather than an empty list, { known: false } rather than zero, an explicit unscored state rather than a number on the same scale. Then each consumer decides what unknown means: a page hides the section, a report marks the gap, and a security rule fails open or closed, as its owner decides. A bot rule that fails open lets some bots through for a while; an authorization check that fails open lets anyone through.
Defaults deserve the same suspicion. In Shore’s first example, a method returns a default of 10 connections when a configuration property is missing. A typo in the file then goes unnoticed, and, he writes, “when customers start using the software, they’ll encounter mysterious slowdowns.” A silent default hides the defect that a failure would have reported. Fail fast on the input instead: reject it, report it, and keep serving with the last good version while it is still safe to run, as the flag client does with a bad update.
Graceful degradation is not about ignoring failures. It is about handling them while keeping them visible. Tell users when a gap matters to them: “Delivery estimate unavailable” serves them better than a blank, and far better than a guess. Tell operators with numbers: count degraded responses per part, and alert when a part stays degraded.
The samples log every degraded part to stay short, but at outage volume the logging itself is load. Gabrielson’s article warns that an error logged at a high rate can turn a CPU-bound application into an I/O-bound one. Count, and log a sample.
Keep the Degraded Path Working
A degraded path runs only when something is already wrong, which makes it some of the least exercised code in a system. The SRE book states the risk: “Remember that the code path you never use is the code path that (often) doesn’t work.” For its overload mode, the book’s remedy is “regularly running a small subset of servers near overload.” Amazon goes further: a service “must run both the fallback and the non-fallback logic continuously,” at which point, Gabrielson notes, the strategy is no longer fallback but failover.
The cheapest exercise is a test. The SRE chapter asks for noncritical backends to be tested both when they are unavailable and when they never respond. It warns that “backends advertised as noncritical can still cause problems on frontends when requests have long deadlines.” A catch block alone handles the first case, not the second. This test blackholes recommendations and checks that the page still loads without them in under a second:
import { test } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
// A stub API whose recommendations
// never answer.
const bodies = {
products: { id: "p1" },
reviews: [],
stock: { known: true, inStock: 3 },
};
const server = createServer((req, res) => {
const [, part] = req.url.split("/");
if (part === "recommendations") return;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(bodies[part]));
});
await new Promise((ready) =>
server.listen(0, "127.0.0.1", ready));
const { port } = server.address();
process.env.API_BASE = `http://127.0.0.1:${port}`;
const { loadProductPage } = await import("./product-page.mjs");
test("the page loads when recommendations hang",
{ timeout: 2000 }, async () => {
const started = performance.now();
const page = await loadProductPage("p1", "u1");
assert.equal(page.product.id, "p1");
assert.equal(page.recommendations, null);
assert.deepEqual(page.degraded, ["recommendations"]);
assert.ok(performance.now() - started < 1000);
});
test.after(() => {
server.closeAllConnections();
server.close();
});
It passes in just over 300 milliseconds, the recommendations budget. With that budget removed, it fails on its own 2-second timeout. The test pins the degradation down, so a later change cannot quietly remove it.
Kill switches need exercise too. On 5 December 2025, 17 days after the November outage, Cloudflare engineers used their kill switch system, which had mitigated incidents before, on a type of rule it had never been applied to. They hit a bug that, the postmortem says, “had existed undetected for many years.”
The kill switch reached the whole fleet within seconds, and sites carrying about 28 percent of the company’s HTTP traffic returned errors for about 25 minutes. A kill switch is a production change. Cloudflare reports that most of its configuration changes now roll out progressively, with real-time health monitoring.
Last, keep the controls and the status page off the path that fails: anything that depends on the failing system fails with it. Cloudflare’s dashboard login depended on Turnstile, which the November outage took down. As the company’s resilience plan notes, customers without an active session or API service token “were not able to log in to Cloudflare in the moment of most need to make critical changes.”
In 2017, an S3 outage kept AWS from updating the individual service statuses on its own Service Health Dashboard, whose administration console depended on S3. My article on complicated and complex systems makes the same point.
A Checklist for Graceful Degradation
Graceful degradation is a mindset more than a coding technique. It means designing for the production you will actually have, where networks are unreliable, services have bad days, and users still need to get their work done. In practice, it comes down to a few habits:
- Classify each dependency for each use. Critical, enhancement, or optional, decided with the people who own the business outcome.
- Give every remote call a budget that cancels the request. Use
AbortSignal.timeout, not a race against a timer, and pass the remaining deadline and the cancellation downstream. - Fail fast where the answer would be wrong. Critical calls, payments, and authorization checks stop the request rather than guess.
- Decide each fallback in advance. Use data you already hold, or do less. If a fallback calls another system, give it a budget, and make sure that system can survive every server calling it at once.
- Keep unknown distinct from zero. Return
nullor an explicit state, and let each consumer decide what unknown means. - Make degradation reversible and visible. Use runtime kill switches and circuit breakers that close by themselves, count every degraded response, and alert when a part stays degraded.
- Exercise the degraded paths. Test each noncritical dependency failing and hanging, and rehearse the kill switches before an incident needs them.
A store that loses its recommendations still takes orders; a store that shows an error page takes none. Fewer incidents become full outages, so on-call engineers spend their nights on the failures that matter. And deciding what is critical forces clear boundaries between components, which pays off even on the days when nothing fails. A well-designed system does not fall apart when a part does. It bends without breaking.
Further Reading
- Avoiding Fallback in Distributed Systems (Jacob Gabrielson, Amazon Builders’ Library, 5 December 2019)
- Addressing Cascading Failures (Mike Ulrich, in Site Reliability Engineering, O’Reilly, 2016)
- Making the Netflix API More Resilient (Ben Schmaus, Netflix TechBlog, 8 December 2011)
- Feature Toggles (aka Feature Flags) (Pete Hodgson, martinfowler.com, 9 October 2017)
- Circuit Breaker (Martin Fowler, 6 March 2014)
- Fail Fast (Jim Shore, IEEE Software, September/October 2004)
- Code Orange: Fail Small Is Complete. The Result Is a Stronger Cloudflare Network (Jeremy Hartman, Cloudflare, 1 May 2026)