Graceful Degradation

The reviews service answered in eleven milliseconds. A clean 503, textbook, exactly what a well-behaved service does when it is briefly overwhelmed: give up fast and say so. Every product page on the site went down anyway.

Not slow. Not hung. This is a different failure than the tax service that got slow or the recommendations strip that wedged a socket. Nothing here waited. One optional dependency failed fast and honest, and the whole page still turned into a 500. Here is the code that did it:

async function buildProductPage(id) {
  const [product, price, stock, reviews, recs, social] = await Promise.all([
    getProduct(id),
    getPrice(id),
    getStock(id),
    getReviews(id),          // ← returned 503 in 11ms
    getRecommendations(id),
    getSocialProof(id),
  ]);

  return renderPage({ product, price, stock, reviews, recs, social });
}

Read it and it looks fine. It fetches the six things the page needs and renders them. The bug is not in any one call. It is in the word all. Promise.all rejects the instant any input rejects, so the moment getReviews said 503, the whole expression threw, the handler caught it, and the customer got a blank error page. The product they wanted to buy was sitting right there in product, already fetched, fully available. They never saw it, because a star rating they did not come for was unavailable.

Nobody decided reviews were load-bearing. Promise.all decided it, quietly, on everyone’s behalf. That is the entire problem this article is about.

Required or optional: pick, for every dependency

The default posture of most code is that every dependency is required. Every await is an implicit “I cannot continue without this.” Chain six of them and you have built a system that is down whenever any of six things is down, which is a much worse guarantee than any single one of those services offers on its own. Six dependencies at 99.9% each, all required, gets you to about 99.4%. You made your reliability worse by adding features.

The shift is small to describe and hard to actually do: for each dependency, decide whether the page owes the user something when it is missing, or whether it can just be gone. Reviews can be gone. The product cannot. Once you have made that call, the code changes to match it.

the reviews service fails fast with a clean errorevery dependency requiredproductokpriceokreviews×recommendationsoksocial proofokHTTP 500 · blank page for everyoneclassified, fail softproductokpriceokreviewshiddenrecommendationsoksocial proofokHTTP 200 · page works, minus reviews
Same failing dependency, two architectures. Treat every call as required and one optional 503 blanks the page. Classify and fail soft, and the reviews section simply is not there.

The refactor is not clever. It is a decision made visible in the types. Wrap every optional call so a rejection becomes a value that says “not available” instead of an exception that unwinds the whole page:

// optional() turns a rejection into a plain result, so it can never abort a Promise.all
async function optional(promise) {
  try {
    return { ok: true, value: await promise };
  } catch (err) {
    return { ok: false, error: err };
  }
}

Now the assembly says out loud which calls are load-bearing and which are decoration:

async function buildProductPage(id) {
  // the one genuinely required call: no product, there is no page, so let it throw
  const product = await getProduct(id);

  // core, but each degrades to a cached value instead of failing (more on this below)
  const [price, stock] = await Promise.all([
    getPriceOrCached(id),
    getStockOrCached(id),
  ]);

  // optional: wrapped, so one failing never touches the others or the page
  const [reviews, recs, social] = await Promise.all([
    optional(getReviews(id)),
    optional(getRecommendations(id)),
    optional(getSocialProof(id)),
  ]);

  return renderPage({
    product,
    price,
    stock,
    reviews: reviews.ok ? reviews.value : null,   // null → the section is omitted
    recs: recs.ok ? recs.value : null,
    social: social.ok ? social.value : null,
  });
}

There is exactly one await left that can take the page down, and it is the one that should: getProduct. Everything else either degrades to a cached value or renders as nothing. A dead reviews service is now a page without a reviews section, which is what it always should have been.

Spend your resilience budget on the core

Classifying dependencies only works if you are honest about which is which, and that is a product question, not an engineering one. You cannot answer it from the code. You have to know what the site is for.

For a store, the answer is not subtle. Browsing, viewing a product, adding to cart, and checking out are the reason the business exists. If those break, you are losing money by the minute. Reviews, recommendations, “14 people are viewing this,” recently-viewed, the wishlist heart icon: nice, measurable, worth having, and completely survivable if they vanish for an hour. Nobody has ever asked for a refund because the “customers also bought” strip was missing.

CORE · must not breakbrowseproduct pageadd to cartcheckoutpaytimeouts, retries, redundancy, a breaker per call · spend the budget hereOPTIONAL · allowed to vanishreviewsrecommendationssocial proofavatarsrecently viewedone fallback or just hide it · nobody loses money
Sort every feature into two lanes. The core path earns real defenses. The optional lane is allowed to disappear, so it gets a fallback or nothing.

Why does the sorting matter so much? Because a resilience budget is real and finite. Every timeout you tune, every fallback you write, every cache you keep warm, every degraded path you test is work, and work you spend on the wishlist icon is work you did not spend on checkout. Treating everything as equally critical is the same as treating nothing as critical: you smear thin defenses across the whole surface and the checkout path ends up no better protected than the “you might also like” strip. The timeouts and bulkheads that keep one slow dependency from draining a shared pool are exactly the kind of defense you pour onto the core lane and barely bother with in the optional one.

Watch a page degrade

Here is the assembled product page from a moment ago, with each service under your control. Flip any of them to failing and watch what happens: the optional sections quietly disappear, the core sections fall back to a cached value with an honest note, and the “Add to cart” button never stops working. Turn everything to failing at once. The page gets plain, but it never breaks.

interactiveA product page you can fail one service at a time

The lesson hiding in the interaction: the page’s floor is set by the core path, not by the sum of its parts. However many widgets you knock out, the customer can still see the product and buy it. That floor is the thing you are protecting, and everything above it is negotiable.

The fallback ladder

“Optional” and “required” are the two ends. Most features live somewhere on a ladder between them, and the useful skill is knowing how far down the ladder a given feature is allowed to fall.

Take live inventory. The best answer is the real count, this second. If the inventory service is unreachable, the next best answer is the count you saw a minute ago, labelled as such. If you do not even have that, you can fall back to a generic “in stock” with no number, which is vague but true often enough to be useful. And if none of that is worth showing, you drop the line entirely. Four tiers, each one softer than the last, each one still better than a broken page.

one feature · fall as far as it needs, no furthersofterLIVEreal-time · 8 left in stockexactly rightCACHEDlast known · about 8 left, seen 2 min agoslightly staleDEFAULTsafe generic · in stockvague but trueHIDDENdrop the element · no stock line at allgone, not brokenthe floor is HIDDEN, never a thrown error the user has to read
One feature, four fallback tiers. Stop at the first one that still works. Each step trades a little accuracy for staying up, and hidden is the floor, never an error.

In code the ladder is a try that walks down the rungs. The live call gets a tight deadline (a slow call is a failed call here, which is why the timeouts come first), and each catch reaches for a softer answer:

async function getStockOrCached(id) {
  try {
    const live = await getStock(id, { signal: AbortSignal.timeout(300) });
    cache.set("stock:" + id, { count: live.count, at: Date.now() });   // keep the ladder stocked
    return { count: live.count, freshness: "live" };
  } catch {
    const cached = cache.get("stock:" + id);
    if (cached) return { count: cached.count, freshness: "cached", at: cached.at };
    return { count: null, freshness: "default", label: "In stock" };
  }
}

The cache write on the happy path is the part people forget, and without it the ladder has no second rung. Your caching layer is usually already holding a recent value for exactly this reason, so “serve stale on failure” is often just reading the cache you already keep and skipping the origin. This is the read half of stale-while-revalidate: serve what you have, refresh in the background, and when the origin is down, keep serving the stale copy rather than nothing.

Same ladder applies to features you might not think of as degradable. If a search page’s fancy learned ranking is down, fall back to plain lexical search: worse results, still results. If an AI-powered answer box times out, fall back to the old keyword FAQ, or hide the box. The move is always the same. Name the softer version, and reach for it in the catch.

Let the breaker trigger the degrade

A circuit breaker and graceful degradation are two halves of one story, and they meet at the catch. The breaker’s job is to notice a dependency is sick and start failing calls to it fast, so your resources stop piling up behind a corpse. That fast failure is useless to the person looking at the page unless something catches it and serves the degraded version instead. The breaker protects your server; the degrade protects your user.

The wiring is one branch. When the breaker is open it throws a CircuitOpenError immediately, and you treat that identical to any other failure on the ladder:

import { CircuitOpenError } from "./circuit-breaker.js";

async function stockSection(id) {
  try {
    return liveStock(await getStock(id));          // getStock is breaker-wrapped
  } catch (err) {
    if (err instanceof CircuitOpenError || err.name === "TimeoutError") {
      return degradedStock(id);                     // cached count, or hide the line
    }
    throw err;                                       // an actual bug, let it surface
  }
}
dependency downnot calledrequestbreaker OPENthrows fasterror shownbroken box where the feature wasdegraded pathserve cached · or hide the sectionpage still worksonly rethrowcatch and degrade
An open breaker is the trigger, not the destination. Fail fast, then route to the degraded path. On its own the breaker just throws, which is a fast error, not a working page.

A breaker with no degraded path behind it is a faster way to show the user the same error. Useful for your server, invisible improvement for your customer. Always land the open signal on a fallback.

Reads bend, writes must not vanish

Everything so far quietly assumed reads. Reads degrade beautifully, because a slightly wrong answer is usually fine: a stock count from a minute ago, last night’s recommendations, a cached price. You can substitute an old value or a generic one and the user is served.

Writes are different, and this is where naive degradation becomes a data-loss bug. If the user submits a review, or adds to cart, or places an order, you cannot “fall back to a cached success.” There is no cached success. The write either happened or it did not, and pretending it did when it did not is how you lose someone’s order and tell them it went through.

READS bendserve something slightly wrong, in placereadorigin downtimeout / 5xxcached / defaultstale but served, page intactWRITES defernever fake success, never silently dropwritestore downcannot commitqueue / outboxaccepted · applied when backa read can show yesterday’s number; a write must be parked and reconciled, never dropped on the floor
A read can serve a stale or default value in place. A write cannot be faked, so when the store is down you park it and apply it later rather than dropping it or lying.

The degraded path for a write is a queue. Accept the request, write it somewhere durable that is not the sick dependency, acknowledge it honestly (“we’ve got it, it’ll post shortly”), and let a background job drain the queue once the store recovers:

async function saveReview(review) {
  try {
    return await reviews.insert(review);              // normal path
  } catch (err) {
    if (!isTransient(err)) throw err;                 // a real validation error: tell the user now
    await outbox.enqueue("review.create", review, { key: review.id });
    return { status: "queued" };                      // accepted, will apply when the DB is back
  }
}

Two things make this safe rather than a fresh source of bugs. The queued write carries an idempotency key (review.id here) so replaying it when the store recovers cannot create duplicates. And you only queue transient failures. A 409 duplicate or a 422 validation error is the store telling you the write is wrong, and queuing that just defers the same rejection, so those still fail loudly to the user. Degrading a write means “the store is temporarily unreachable,” never “the request was invalid.”

Feature flags are the manual degrade lever

Everything above degrades automatically, on failure. Sometimes you want to degrade on purpose, before anything has technically failed, because you can see it coming. Traffic is spiking, a dependency is wobbling, and you would rather turn off the expensive recommendations query now to save the checkout path than wait for the recs service to fall over and take shared capacity with it.

That lever is a feature flag, and at its core it is embarrassingly simple: config you can flip without a deploy.

// flags.js: a kill switch is config you can change in seconds, no redeploy
const flags = new Map(Object.entries({
  "recommendations": true,
  "social-proof": true,
  "live-inventory": true,
}));

export const enabled = (name) => flags.get(name) !== false;
export const kill = (name) => flags.set(name, false);   // the ops toggle you flip under load

Then every optional feature checks its flag before it does any work, and a flipped flag degrades it to the same “hidden” state a failure would:

const recs = enabled("recommendations")
  ? await optional(getRecommendations(id))
  : { ok: false };                                       // off by choice, not by failure

The real value shows up during an incident. When the recs service is melting, you do not want to ship a code change through CI to disable it. You want to flip one switch and have every server stop calling it within seconds, buying the core path breathing room while you fix the actual problem. That is the “kill switch” the flag exists for, and it is the single most useful flag category to have wired up before you need it.

Load shedding: drop the cheap work to save the checkout

Degradation so far has been about dependencies failing. Load shedding is about you failing, or about to. When traffic exceeds what the box can serve, something is going to get dropped no matter what. Your only choice is whether you drop it deliberately or let the overload pick for you, and the overload always picks the worst possible victim, which is “all of it, at once, including checkout.”

The deliberate version keeps a priority on every kind of work and, as pressure rises, refuses the low-value work first so the high-value work still gets served. Checkout outranks browse outranks search outranks recommendations outranks analytics. Under mild load everyone gets in. Under real load, the analytics beacon and the recs query get a fast 503 and the checkout sails through.

// admission control: shed low-priority work first as the system heats up
const PRIORITY = { checkout: 0, browse: 1, search: 2, recommendations: 3, analytics: 4 };

function admit(job, load) {
  // load is 0..1, derived from queue depth or event-loop lag, not a guess
  const cutoff = load > 0.9 ? 1 : load > 0.75 ? 3 : Infinity;
  if (PRIORITY[job.kind] > cutoff) {
    return shed(job);                 // fast, cheap rejection: no work done
  }
  return run(job);
}

This is the same instinct as rate limiting pointed inward: rate limiting protects you from one noisy caller, load shedding protects the important work from the total load. The subtlety is measuring load from something real, like event-loop lag or queue depth, rather than a fixed request-per-second number that has no idea how expensive the current requests are. Shedding is a last resort, below fallbacks and flags, but when the alternative is the box tipping over and taking everything with it, dropping the analytics beacon to save the order is an easy trade.

Tell the user the truth, quietly

A degraded page still has a person looking at it, and how you talk to them decides whether degradation feels like reliability or like breakage. Two failures to avoid, in opposite directions.

The first is alarming people over nothing. A missing avatar does not need a red banner reading “AVATAR SERVICE ERROR.” It needs a default avatar, silently. If a section is purely optional and it is gone, the right amount of explanation is usually none. The user does not know the recommendations strip is supposed to be there, and telling them it broke invents a problem in their head that did not exist a second ago.

The second is lying by omission, which matters the moment you show degraded data rather than hide a feature. A cached stock count presented as live is a small dishonesty that becomes a real one when the customer buys the last unit that sold out ten minutes ago. Show the staleness: “prices updated 5 minutes ago,” “showing recent results.” One quiet line. It is the difference between a system that is gracefully honest and one that is quietly wrong.

On the frontend it has an older name

If you have done any web work you have met this idea already, wearing the name progressive enhancement: build a page that works with plain HTML, then layer on CSS and JavaScript so that when the fancy layer fails to load or run, the baseline still functions. A form that submits via a real action still submits when the JS enhancement that would have made it an in-place fetch fails to load. That is graceful degradation aimed at the browser instead of the backend, and it is the same mental move: identify the baseline that must work, and treat everything above it as enhancement that is allowed to not.

The browser hands you soft fallbacks for free if you use them. An <img> with a broken src fires an error event, so a missing avatar becomes a default image with a two-line handler rather than a broken-image icon:

img.addEventListener("error", () => { img.src = "/default-avatar.svg"; }, { once: true });

In a component tree the tool is an error boundary: a wrapper that catches a render error in the subtree below it and shows a fallback in that spot instead of unmounting the whole app. One recommendation card throwing should blank that one card, not the page. Note the sharp edge, the same in every framework: error boundaries catch errors thrown while rendering, not errors thrown in event handlers or in async callbacks. A click handler that throws is yours to try/catch. The boundary will not see it.

Test the degraded path or it rots

Here is the quiet tragedy of every degradation you just wrote. The happy path runs a million times a day and any break in it is obvious within minutes. The degraded path runs only during an incident, which is to say almost never, which is to say it silently rots. Someone refactors getReviews, the optional wrapper stops catching the new error shape, and nobody notices for months, until the reviews service actually goes down and takes the page with it exactly the way it would have before you did any of this work. The fallback you never test is not a fallback. It is a comment that claims there is a fallback.

So test the failure explicitly. Force the dependency to fail and assert that the core still renders and the optional part is cleanly gone:

test("product page survives a dead reviews service", async () => {
  reviews.getReviews = () => Promise.reject(new Error("503"));   // force the outage

  const page = await buildProductPage("sku_1");

  expect(page.status).toBe(200);        // core still served
  expect(page.reviews).toBeNull();      // section quietly omitted, not thrown
  expect(page.price).toBeDefined();     // the thing they came for still works
});

That is the cheap version, and it belongs in your test suite next to the happy path. The expensive version is deliberately breaking things in a real environment, game-day exercises and fault injection, so you find out during a scheduled Tuesday that the fallback rotted rather than during a real outage at 3am. Whichever you can afford, the principle is fixed: a degraded path that is never exercised will not work when you finally need it, and you will discover that at the worst possible time.

Summary

  • A resilient system does not sit at “perfect” until it flips to “dead.” It degrades: sheds the non-essential and keeps the core working, so a failed recommendations service is a page without recommendations, not a broken page.
  • The mindset shift is per-dependency classification. Most code treats every await as required, and Promise.all enforces exactly that. The moment you assemble something from parts that can independently be missing, wrap the optional ones (or use Promise.allSettled) so one failure cannot abort the rest.
  • Which features are core is a product decision, not an engineering one. Sort every feature into “must not break” and “may vanish,” then spend your finite resilience budget on the core lane and let the optional lane fail soft.
  • Most features sit on a fallback ladder: live → cached → default → hidden. Walk down it in a try/catch, stop at the first rung that still works, and make the floor “hidden,” never a thrown error the user has to read.
  • The circuit breaker and the degrade meet at the catch. The breaker fails fast to protect your resources; the degraded path turns that fast failure into a working page. A breaker with no fallback behind it just shows the error sooner.
  • Reads bend, writes must not vanish. A read can serve a stale or default value in place. A write that cannot commit gets parked in a durable queue with an idempotency key and applied when the store recovers, never faked and never dropped. Only defer transient failures; a validation error still fails loudly.
  • Feature flags are the manual lever. A kill switch lets you degrade a struggling feature on purpose, in seconds, without a deploy. Keep flag evaluation behind one thin adapter so you can still delete flags later.
  • Load shedding is degradation aimed at your own overload: keep a priority on every kind of work and drop the cheap work first so checkout survives the flash sale.
  • Communicate honestly and quietly. Hide a dead optional feature with no fanfare; label stale core data with its provenance; acknowledge a deferred write truthfully; save the real error for when the core itself is broken.
  • Test the degraded path, because it only runs during incidents and rots in silence otherwise. A fallback you never exercise is a comment, not a safety net.
  • Use it for any user-facing surface assembled from more than one dependency, which is nearly all of them. Go easy when there is genuinely one required dependency and no meaningful “less” to fall back to, where the honest move is a clear error, not a fake success.