Failure Analysis

The Anatomy of a Cascading Failure

Jordan Kessler 10 min read
Dependency graph showing cascade propagation paths through service tiers

The triggering failure in a cascade is rarely the failure that causes the outage. A payment database may add 300 milliseconds to query latency, but the outage arrives later, when upstream requests occupy every connection, retries multiply the work, queues consume memory, and healthy services begin timing out. The first fault opens the dependency chain. Amplification along that chain exhausts capacity.

That distinction changes how an incident should be investigated. The question is not only, "Which service failed first?" It is, "Which downstream mechanisms converted a bounded degradation into system-wide resource saturation?" A useful post-incident analysis reconstructs those mechanisms in order: dependency calls, timeout budgets, retry behavior, queue growth, connection allocation, and the detection windows available at each step.

The first edge in the dependency graph

Consider a request path as a directed graph. The checkout service calls the payment service. The payment service calls a fraud scoring service and a ledger database. The notification service receives an event after payment succeeds. Each edge carries more than data. It carries latency expectations, concurrency, timeout values, retry policy, connection limits, and failure semantics.

A degradation at one node becomes a cascade when those edge properties amplify the original condition. A slow dependency holds a caller's worker longer. The caller accumulates in-flight requests. Its queue grows, so callers above it wait longer. Those callers may retry, creating additional work for the already degraded dependency. Eventually, a resource limit is reached somewhere else in the graph.

The resource that fails second is often more important operationally than the original fault. It might be a connection pool, a thread pool, a Kubernetes pod's memory limit, a message broker partition, or a shared database CPU budget. The resulting symptom can point in the wrong direction. Elevated checkout latency may be caused by payment connection exhaustion, which may be caused by a fraud service timeout, which may be caused by a database lock or storage event.

This is why a single alert such as HTTP 500 rate does not describe a cascade. It identifies an outcome, not the propagation path. The path must be reconstructed from timestamps and resource signals.

A synthetic payment cascade, step by step

Take a three-tier web application with a payment processing backend and a notification service. The edge tier accepts checkout requests. The checkout service calls payment over HTTP. Payment calls fraud scoring over gRPC and writes an authorization record to a ledger database. After authorization, payment publishes a message for notification.

The normal dependency graph looks like this:

client
  |
edge
  |
checkout --> payment --> fraud scoring
                 |
                 +----> ledger database
                 |
                 +----> notification queue --> notification workers

Assume the following ordinary operating limits:

  • Checkout has 500 worker slots and a 2-second outbound timeout to payment.
  • Payment maintains a pool of 200 database connections and retries a failed fraud request twice.
  • Fraud scoring normally responds in 80 milliseconds, with a 500-millisecond client timeout.
  • The ledger database normally has enough capacity for the expected write rate, but its connection pool is shared by authorization reads and writes.

At 14:00, a storage problem causes the fraud scoring service to respond in 1.5 seconds for most requests. It has not stopped responding, so a basic availability check remains green. This is the first detection window: the dependency is degraded, but the request path still completes.

At 14:01, payment requests begin to exceed the fraud timeout. Payment retries each failed call twice. One logical checkout now produces up to three fraud requests. The fraud service receives more work precisely because it is slow. This is retry amplification. If 100 requests per second arrive at payment, the fraud edge can approach 300 requests per second before accounting for overlapping timeouts.

At 14:02, payment workers remain occupied while waiting for fraud responses. The payment service accepts more inbound requests, but its effective concurrency falls because each request has a longer lifetime. Its queue depth rises. Checkout waits on payment, and checkout workers stay occupied for close to their 2-second timeout.

At 14:03, the checkout worker pool reaches its limit. New requests queue at the edge or receive errors. The edge now reports a checkout failure, even though the original fault is still the fraud service's latency. The apparent primary symptom has moved two edges away from the trigger.

At 14:04, payment has accumulated enough timed-out work that cancellation is not immediate at every layer. Some database operations started before the upstream request timed out. Authorization reads and writes continue consuming the shared ledger connection pool. Connection acquisition latency increases, and payment begins timing out on database operations as well.

At 14:05, the ledger database is handling fewer successful transactions but more concurrent and abandoned work. Queue depth and CPU utilization rise. Payment now has two slow dependencies, fraud scoring and the ledger. The service's error rate increases, and its circuit breaker may open for one dependency while requests to the other continue to consume the remaining capacity.

At 14:06, successful payments that did complete publish notification messages faster than notification workers can process them. The notification queue grows. If notification shares credentials, network bandwidth, or a message broker cluster with another workload, the queue becomes a further source of pressure. A side effect of the original checkout degradation is now visible in an asynchronous subsystem.

At 14:07, operators see elevated latency and errors in edge, checkout, payment, and ledger. The fraud service may show only moderate error rates because it is still returning responses. The outage is no longer a single-service incident. It is a dependency chain with several saturated resources and multiple detection timestamps.

The sequence can be expressed compactly:

  1. Fraud latency increases without immediate unavailability.
  2. Payment retries multiply fraud work.
  3. Payment concurrency and checkout wait time increase.
  4. Checkout worker slots and edge capacity are exhausted.
  5. Timed-out requests continue consuming ledger connections.
  6. Ledger latency rises and payment acquires a second failure mode.
  7. Notification queue depth grows as asynchronous processing falls behind.

The incident is not explained by saying "fraud scoring went down." It is explained by identifying the amplification at each edge.

Detection windows are part of the failure model

Every cascade has intervals in which intervention is possible before the next resource limit is reached. These detection windows are often short, but they are not identical. A latency alert on fraud scoring may provide several minutes of lead time. A connection pool saturation alert may provide seconds. An edge 500-rate alert may arrive only after the cascade has crossed the system boundary.

Metrics should therefore be aligned with the mechanics of propagation. For request volume, a Prometheus query such as rate(http_requests_total[5m]) is more useful when paired with latency and outcome labels. For a dependency, compare request rate, timeout rate, and in-flight requests. For queues, inspect both depth and age, since a shallow queue with old messages can be more urgent than a deep queue processing quickly.

Useful signals include:

  • Dependency latency by percentile, not only average latency.
  • Timeouts separated from explicit application errors.
  • Retry volume and retry rate by caller.
  • Connection pool utilization, wait time, and acquisition failures.
  • Worker utilization, queue depth, and queue age.
  • Container memory pressure, CPU throttling, and pod restart rate.
  • SLO burn rate for the affected user journey.

Correlating these signals by time is essential. If fraud latency rises at 14:00, retries at 14:01, checkout queue depth at 14:02, and ledger connection wait at 14:04, the order provides stronger evidence than any isolated graph. Distributed tracing can add request-level detail, but traces alone may miss rejected requests, queued work, and resource exhaustion. Metrics show the population behavior that turns a few slow requests into a capacity event.

Why the usual safety controls can still fail

Circuit breakers, bulkheads, backpressure, and bounded retries are useful because they limit propagation. They are not independent guarantees.

A circuit breaker can stop calls to a failing dependency, but its threshold may be based on errors rather than latency. In the payment example, fraud scoring is slow and mostly successful. The breaker may not open until the caller is already saturated. A breaker can also shift failure upward. Once payment rejects fraud calls, checkout still needs a defined fallback, such as a pending state or a controlled rejection. Otherwise, the breaker simply changes where users observe the error.

The bulkhead pattern protects one resource pool from another, but only if the partition follows the dependency risk. Separate pools for authorization reads and writes may prevent a slow read path from consuming every write connection. Separate worker pools for interactive checkout and asynchronous reconciliation may preserve the user path. A nominally separate pool that shares the same database CPU or network link is a narrower boundary than its configuration suggests.

Backpressure must propagate in a form the upstream caller understands. If a queue accepts unlimited work while workers are blocked on a dependency, the queue becomes a memory and latency reservoir. If a service returns overload signals quickly, callers can shed load or enter a degraded mode. Retry policy also needs a budget. Exponential backoff with jitter reduces synchronization, but retries still consume capacity. A retry should have a clear deadline and should not outlive the request that initiated it.

Timeouts require the same discipline. A 2-second edge timeout, a 2-second checkout timeout, and a 2-second payment timeout do not create a 2-second end-to-end budget. They can create nested work that continues after the caller has abandoned it. Deadlines should propagate through the call graph, and services should stop work when the remaining budget is exhausted.

Not every cascade is preventable

Some cascades remain latent until load reaches a threshold. A system may tolerate a slow dependency at 20 requests per second and fail at 200 because queueing delay grows nonlinearly once utilization approaches capacity. A capacity model based only on normal traffic will not reveal that boundary. Load tests need to vary dependency latency, error rate, retry behavior, and concurrency, not just request volume.

Other cascades begin with faults outside the application's control, such as a regional network event, a provider outage, or a hardware failure. Prevention cannot mean guaranteeing that no chain reaction will ever occur. The practical boundary is narrower: teams can control whether the application amplifies the fault, whether critical paths have isolation, and how early operators can detect the first meaningful change.

There are also intentional tradeoffs. Rejecting a payment immediately protects the ledger but harms availability for that transaction. Accepting it into a durable queue may preserve user intent but introduce reconciliation risk. A circuit breaker may reduce load while increasing visible errors. These choices belong in the service's failure contract, not in an emergency-only configuration change.

Make the dependency chain operational

A useful runbook starts with a graph that names each synchronous and asynchronous edge. For every edge, record the timeout, retry limit, concurrency limit, queue behavior, circuit breaker conditions, and fallback. Then annotate the graph with the resource each edge can exhaust. This turns "payment is slow" into a sequence of testable hypotheses.

During an incident, establish the earliest changed signal before chasing the loudest alert. Compare dependency latency with request volume. Check whether retries are creating additional load. Inspect connection wait time rather than connection count alone. Determine whether cancellation reaches the operation that is still consuming resources. Then identify the next detection window, the interval before another pool, queue, or SLO crosses its threshold.

Cordant watches production systems and diagnoses incidents when they start, using this kind of operational context to propose and apply a fix. The useful target is not an alert for every symptom. It is earlier action on the dependency edge that is about to amplify the fault, with on-call engineers woken only when a human decision is genuinely required.

The durable lesson from a cascade is precise: map the dependency chain, measure the resources at each edge, and treat detection windows as capacity that can be spent. The first failing component explains the trigger. The chain analysis explains the outage.

← Back to The Runbook

Put it into practice

If cascading failures in your system read as chaotic after the fact, the detection window exists before the cascade propagates. Cordant maps the dependency chain to find that window and act within it.