Distributed Systems Fail in the Gaps Between Teams
Most outages start where one team’s service meets another’s. Contracts, timeouts, retries and ownership that stop one slow dependency from taking everything down.
- 10 min lezen
Deze pagina is nog niet beschikbaar in het Nederlands, dus je leest het Engelse origineel.
When a distributed system goes down, the root cause is rarely a single bug inside a single service. It is usually an assumption at a boundary: a dependency that was “always fast”, a field that was “never null”, a retry that seemed harmless until ten thousand clients did it at once. These gaps line up with team boundaries, because that is where knowledge stops. This post covers why that happens and the specific practices that close the gaps.
Why It Matters
Conway’s law says systems mirror the communication structure of the organizations that build them. The corollary for reliability is uncomfortable: the places where teams talk least are the places where failure handling is weakest. Each team tests its own service well; nobody tests what happens when the neighbour’s service is slow, returns something unexpected, or comes back after an outage to a flood of retries.
How One Slow Service Takes Everything Down
A cascading failure starts small and grows through the dependency graph:
- Inventory slows from 50 ms to 5 s
- Checkout threads wait on it, with no timeout
- Checkout runs out of connections, all its endpoints slow
- Clients retry, doubling the load
- Unrelated pages that share checkout fail too
Notice that nothing crashed. A slowdown in one place became an outage everywhere because waiting is contagious: a caller without a timeout holds resources while it waits, and retries add load exactly when the system has the least capacity.
The Assumptions That Fail
The “fallacies of distributed computing”, collected at Sun Microsystems in the 1990s, are still the best checklist. The ones that cause most incidents between teams:
- The network is reliable. Calls fail, hang and time out, and sometimes succeed on the server after the client gave up.
- Latency is zero. A dependency that is fast on average is slow at the 99th percentile, and a page that calls ten services hits someone’s slow tail on most requests.
- The other side behaves as documented. With enough users, every observable behavior of an API, including undocumented ones, is relied on by somebody (Hyrum’s law). Changing it is a breaking change whether or not the contract says so.
- There is one administrator. Each service has its own owner, release schedule and priorities.
Practices That Close the Gaps
Make Contracts Explicit
- Write down what crosses the boundary: request and response schemas, error codes, latency expectations (for example “p99 under 300 ms”), rate limits and which operations are idempotent.
- Validate inputs at the boundary instead of trusting them.
- Use contract tests, so a provider learns about a breaking change before its consumers do.
- Version contracts, and add fields instead of changing their meaning.
Every Network Call Gets a Timeout and a Deadline
A timeout bounds how long one call may take; a deadline bounds the whole request and is passed down, so a downstream service stops working on a request the user already abandoned. Set timeouts from measured latency, not defaults (many HTTP clients default to no timeout at all).
Retry Carefully
| Retry when | Don’t retry when |
|---|---|
| The error is transient (timeout, 503, connection reset) | The error is permanent (400, 401, validation failure) |
| The operation is idempotent, or uses an idempotency key | Repeating it could charge, send or create twice |
| You use exponential backoff with jitter | Every client would retry at the same instant |
| It is one layer, with a small cap | Several layers each retry three times (3 × 3 × 3 = 27 calls) |
Jitter (a random delay) spreads retries out so they don’t arrive in synchronized waves. A retry budget, such as “retries may add at most 10% extra traffic”, keeps retries from turning a blip into an outage.
Retries multiply across layers. If each of three layers retries a failing call three times, a single user request can turn into 27 calls to the struggling service at the bottom, exactly when it is least able to handle them:
Fail Fast and Degrade Gracefully
- Circuit breakers stop calling a dependency that is clearly failing, give it time to recover and return a fast fallback instead of waiting.
- Bulkheads give each dependency its own pool of connections or threads, so one slow dependency can’t exhaust them all.
- Load shedding rejects some work early (with a 429 or 503) when overloaded, so the work you accept still completes.
- Graceful degradation: show the product page without recommendations rather than not at all. Decide in advance which features are optional.
A circuit breaker moves between three states:
Circuit Breaker States
Give Every Boundary an Owner
- One owning team per interface: it owns the contract, the dashboards and the alerts, and is paged when it breaks. Shared ownership usually means no ownership.
- Dashboards per user journey, not per service, make the gaps visible, because they show the whole path a request takes.
- Joint, blameless incident reviews are where teams learn the assumptions they didn’t know they had.
Going Deeper: Budgets, Jitter and Queues
Pass the Deadline Down
Timeouts set independently at every layer don’t add up. If the user-facing request has 2 seconds, a service three calls deep shouldn’t start a 5-second operation. Propagate a deadline (an absolute time, or the remaining budget) with the request, and let each layer use what is left: give up early when the remaining budget is smaller than the call usually takes. gRPC does this natively with deadlines; over HTTP, a header with the remaining milliseconds works.
Back Off With Jitter
Retries that all wait the same 100, 200, 400 ms arrive in synchronized waves. Randomizing the delay (“full jitter”, popularized by AWS) spreads them out:
// attempt = 0, 1, 2, … ; base and cap in milliseconds
const delay = Math.random() * Math.min(cap, base * 2 ** attempt);Queues Explode Near Full Capacity
Latency doesn’t rise gradually as a service gets busier. In queueing terms, the waiting time grows roughly with utilization / (1 − utilization):
| Utilization | Wait, in service times |
|---|---|
| 50% | about 1 |
| 90% | about 9 |
| 95% | about 19 |
A dependency that is “only a bit slower” at peak is often a queue close to full. The fixes:
- Load shedding: reject early with a clear error when queues are long.
- Backpressure: tell callers to slow down.
- Bulkheads: separate pools per dependency, so one slow dependency can’t take every thread.
Cut the Tail With Hedged Requests
For read-only calls where p99 latency matters, a hedged request sends a second copy to another replica if the first hasn’t answered within, say, the p95 latency, and uses whichever answers first. It costs a few percent more load and can cut tail latency dramatically, as described in Google’s “The Tail at Scale”. Use it only for idempotent operations, and never as a substitute for fixing an overloaded service.
A Checklist for Any Dependency
- What is its timeout, and does it fit inside our own deadline?
- Is the call idempotent, and what do we retry, how often, with what backoff and jitter?
- What do we show the user if it is slow or down?
- Which contract do we rely on, and is it tested by both sides?
- Who owns it, and who is paged when it breaks?
Test Yourself: Scenarios
Think through your answer, then compare.
Scenario 1
The Slow Payment Provider
Your checkout service calls a payment provider. Its latency jumps from 200 ms to 8 seconds. Checkout uses a 30-second timeout and retries failed calls 3 times. What happens, and what would you change?
Antwoord tonenAntwoord verbergen
What happens: each checkout request holds a connection and a worker for up to 8 seconds, and with retries up to 30 seconds or more. Threads and connections fill up, so all of checkout slows down, including requests that don’t need payments, and users retry, adding load.
What to change:
- Timeout from the provider’s normal latency and the user’s budget: around 1–2 seconds, not 30.
- Retries only for errors that can succeed, only with an idempotency key, with backoff and jitter.
- Circuit breaker, so checkout fails fast and shows “payment is delayed, we’ll confirm by email” instead of hanging.
- Bulkhead: a separate connection pool, so payments can’t starve the rest of checkout.
Scenario 2
Retrying an Order
A teammate wants to add automatic retries to POST /orders. What must be true first?
Antwoord tonenAntwoord verbergen
POST isn’t idempotent by default: if the first request succeeded but the response was lost, a retry creates a second order and charges the customer twice.
- Client: send an idempotency key per user intent.
- Server: store the key with the result, and return the original result for repeats, including while the first request is still in progress.
- Only then retry: on timeouts, connection errors and 5xx or 429 responses, a small number of times, with backoff and jitter.
Scenario 3
Setting a Timeout
Your page must respond within 2 seconds. It calls a profile service (p99 400 ms) and then a recommendations service (p99 600 ms), one after the other. What timeouts would you set?
Antwoord tonenAntwoord verbergen
Work from the budget, not from each service in isolation.
- Budget: keep about 300 ms for your own processing, leaving 1,700 ms for the two calls.
- Timeouts just above each p99 fit with a little slack: for example 500 ms for profiles and 800 ms for recommendations.
- Better: pass the remaining deadline, so recommendations gets whatever profiles didn’t use.
- On failure: recommendations is optional, so show the page without them; profiles may be essential, so its failure is an error.
- Best: if the calls are independent, run them in parallel and the budget problem mostly disappears.
Scenario 4
Paged for Someone Else’s Outage
Your team is paged at night because a dependency owned by another team is down. Nobody on your team can fix it. What should change?
Antwoord tonenAntwoord verbergen
Alerts should go to the people who can act.
- Your team is alerted on your symptoms (errors and latency your users see), with a runbook: confirm the dependency is the cause, switch to the degraded mode, escalate to the owning team’s on-call.
- The owners need their own alerts on their service level objectives, so they find out before you do.
- Afterwards, a joint, blameless review: what contract (latency, error behaviour, capacity) did you assume, was it written down, and what timeout, fallback or circuit breaker was missing?
Key Takeaways
- Outages usually start at boundaries between teams, where assumptions about latency, data and retries are implicit.
- Slowness is contagious: a caller without a timeout holds resources while it waits, and retries add load during the worst moment.
- Give every network call a timeout and pass a deadline down the chain.
- Retry only idempotent operations on transient errors, at one layer, with exponential backoff, jitter and a budget.
- Make contracts explicit and tested, and give every interface an owner who is paged when it breaks.
The boundaries themselves are an architecture decision: the architecture decisions that matter more than your tech stack covers how to draw them so fewer calls cross teams in the first place. And when the gaps are known but never fixed, they become the kind of debt described in technical debt is not the enemy.
Sources
- AWS Builders’ Library: Timeouts, retries and backoff with jitter
- Google SRE book: Handling overload and Addressing cascading failures
- Jeffrey Dean and Luiz André Barroso: The Tail at Scale (Communications of the ACM, 2013)
- Martin Fowler: Circuit breaker
- AWS: Summary of the Amazon DynamoDB service disruption in the Northern Virginia (US-EAST-1) region (October 2025)
- Melvin Conway: How do committees invent? (1968)
- Hyrum Wright: Hyrum’s law
- Arnon Rotem-Gal-Oz: Fallacies of distributed computing explained
Tags
Veelgestelde vragen
Should every call have a retry?
NoRetry only operations that are safe to repeat (idempotent), only on errors that might succeed next time, a small number of times, with exponential backoff and jitter. Retrying at every layer multiplies traffic during an outage.
What is a good timeout?
Base it on the dependency’s real latency, typically a little above its 99th percentile, and keep the sum of downstream timeouts inside the caller’s own deadline. A timeout longer than the user is willing to wait protects nobody.
Is this only a microservices problem?
NoAny system that calls another system over a network has it, including a frontend calling a backend-for-frontend, or a monolith calling a payment provider. Microservices just create more of these boundaries.
What is a circuit breaker?
A wrapper around calls to a dependency that stops calling it for a while after too many failures, returning an error or fallback immediately. It protects both your service from waiting and the dependency from extra load while it recovers.
What is an idempotency key?
A unique value the client sends with a request that should take effect once, such as creating an order. The server stores the key with the result and returns the original result for repeats, which makes retries safe.
What is graceful degradation?
Continuing to work with reduced functionality when a dependency fails, for example showing a page without recommendations or with cached prices, instead of failing the whole request.
What is a service level objective (SLO)?
A target for how a service should behave from its users’ point of view, such as 99.9% of requests succeeding within 300 ms over 30 days. SLOs make implicit expectations between teams explicit and drive alerting.
What is a retry budget?
A limit on how much extra traffic retries may add, such as 10% of normal requests. Once the budget is used up, failures are returned instead of retried, so retries can’t multiply load during an outage.