The question this answers
When my client asks a registry where a service is, what is that answer actually worth?
A lookup returns the set of instances the registry believed healthy as of its last update, propagated to this client with some lag. It is never a claim that the returned address will accept a connection, and never a claim that the set is complete. The only authoritative health check is the connection attempt itself.
Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.
A client knows a list of addresses and when it fetched them. It does not know whether an instance died one millisecond after the registry answered, whether the registry itself was serving from a partitioned replica, or whether a healthy instance is missing from the list because its heartbeat was dropped. Every entry is a past-tense observation presented in the present tense.
A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.
Four layers of staleness, and they add
Ask how out of date a discovery answer can be and most people name one number — the health-check interval. There are four, and they compose additively, not by taking the maximum.
Instance to registry. The instance heartbeats every H seconds and the registry evicts after missing K of them. A crashed instance stays listed for up to K·H seconds. With the common 30s/3 configuration that is 90 seconds before anything else even begins.
Inside the registry. The registry is replicated. If it is eventually consistent, one replica may not yet know about an eviction another has recorded. If it is consensus-backed, a follower serving a stale read can lag the leader. Either way there is a propagation term.
Registry to client. Clients either poll on an interval or hold a watch. A poll interval of 30s adds up to 30s. A watch is much better — push, not pull — but a watch that silently dies degrades to "never updates", which is the worst possible failure and is invisible without an explicit staleness metric.
Inside the client. The resolved set is cached, DNS answers are cached (often past their TTL, and JVM defaults historically cached forever), and connection pools hold open sockets to addresses resolved minutes ago. A pool that never re-resolves will keep talking to a decommissioned instance until its connections break.
Add a realistic set of numbers — 90s eviction, 5s replication, 30s poll, 60s connection reuse — and a terminated instance can receive traffic for roughly three minutes. That number is worth computing for your own stack, because everyone underestimates it and the estimate is usually the first layer alone.
| Layer | Mechanism | Typical contribution | How to shrink it |
|---|---|---|---|
| Instance → registrytypical | Heartbeat interval × missed-beat threshold | 30–90s | Explicit deregistration on shutdown; shorter TTL |
| Within the registrytypical | Replication or follower-read lag | 0–10s | Leader reads, or accept the lag knowingly |
| Registry → clienttypical | Poll interval, or watch latency | 0–30s | Watches instead of polling, plus a watch-liveness metric |
| Within the clienttypical | Resolver cache, DNS TTL, connection pool reuse | 30–300s | Bounded pool lifetime; re-resolve on error |
The registry has a consistency model, and you have to pick one
This is the part Architecture’s pattern write-up does not need and you do. A registry is shared mutable state read by every process in your fleet, which makes it a distributed data store, which means it faces the same choice as any other (CAP: What the Theorem Actually Says).
A consistent registry — etcd, ZooKeeper, Consul with consistent reads — gives every client the same view and a totally ordered history of membership changes. During a partition, the minority side cannot serve reads that are guaranteed fresh and typically refuses. The consequence is severe and worth stating plainly: if discovery is unavailable, every service in the fleet loses the ability to find every other service, all at once. A registry outage becomes a total outage, even though every instance is healthy and every network path between them works.
An available registry — Eureka, a gossip-based membership, a DNS-based scheme — keeps answering during a partition with possibly-stale data. It never takes the fleet down, and in exchange it will sometimes hand out addresses of instances that are gone, and sometimes omit instances that are fine.
The Eureka design contains the insight worth stealing: self-preservation mode. If the registry suddenly stops hearing from a large fraction of instances, the far more likely explanation is that *the registry’s* network broke, not that half your fleet died simultaneously. So it stops evicting. Prefer stale entries — which cost a failed connection and a retry — over mass eviction, which costs an outage. The general rule: when a failure detector’s inputs fail en masse, doubt the detector, not the fleet.
- registry-a — sees instances 1–4
- registry-b — sees instances 5–8
- r1believes “instances 5–8 are dead and should be evicted”✕ and it is false
- r2believes “instances 1–4 are dead and should be evicted”✕ and it is false
- c1believes “the service has four healthy instances”✕ and it is false
Every node above is acting on what it believes. Nothing in the cluster tells the mistaken one that it is mistaken.
Fail static, not closed
The single most valuable design rule in this lesson: a client that cannot reach the registry should keep using the last set of addresses it had. Not fail. Not return an empty list. Keep the last known good configuration and carry on.
The reasoning is asymmetric in a way that makes the choice easy. If the registry is down and the instances are up, using stale data works perfectly. If the registry is down and some instances are also down, using stale data costs you failed connections to those instances — which your client already has to handle, because instances die between lookups anyway. There is no scenario in which failing closed is better, and one enormous scenario in which it is catastrophic.
The corollaries follow: never let the discovery client return an empty list from an error; write the last good set to local disk so a restart during a registry outage still works; and treat "resolved set is empty" and "could not resolve" as different conditions with different handling. Fleets have been taken down by a registry blip precisely because those two were conflated.
This is the same instinct as Graceful Degradation: Which Dependency Is Actually Critical: when a control-plane dependency fails, the data plane should keep running on its last instructions.
1class Discovery {2 private lastGood: Endpoint[] = loadFromDisk() // survives restarts3 private lastUpdated = 04 5 async resolve(service: string): Promise<Endpoint[]> {6 try {7 const fresh = await registry.lookup(service)8 if (fresh.length > 0) { // never accept an empty set9 this.lastGood = fresh10 this.lastUpdated = Date.now()11 saveToDisk(fresh)12 }13 } catch {14 // Registry unreachable. Do NOT fail, do NOT return [].15 metrics.gauge('discovery.staleness_ms', Date.now() - this.lastUpdated)16 }17 return this.lastGood18 }19}Discovery is not health checking, and cannot be
Because every layer above is lagging, the registry’s idea of "healthy" is always behind reality. So the client needs its own, closer-in health signal, and it already has one: the outcome of the requests it just sent.
That is what passive health checking, outlier detection and per-endpoint circuit breaking are for. The client observes that endpoint 3 has returned five consecutive connection errors, ejects it from its own local pool for thirty seconds, and re-admits it tentatively afterwards. This reacts in seconds where the registry reacts in minutes, and it requires no coordination with anything (the pattern itself is Architecture’s; the point here is *why* it is mandatory rather than optional).
The division of responsibility is clean once stated: the registry supplies the candidate set; the client decides which candidates are usable right now. Systems that rely on the registry alone spend their incident time waiting for eviction; systems that rely on client-side ejection alone lose track of newly added capacity.
One warning about the other direction. A *deep* health check — one that fails when a shared downstream dependency is unavailable — makes every instance report unhealthy simultaneously, and the registry dutifully evicts your entire fleet for a problem that made it only partially degraded. Health checks should report "can this instance serve" and not "is the whole system happy" (Correlated Failure: The Independence Assumption Is Usually False, Cascading Failure: When the Response to Failure Causes More Failure).
DNS is discovery, with the sharp edges filed off and hidden
DNS is the oldest service-discovery system and it is still the most common, because it needs no client library. It is worth being precise about what it cannot do.
It has no per-instance health signal — a record is present or absent, with removal governed by TTL rather than by liveness. TTLs are advisory and widely ignored: resolvers round them up, stub resolvers cache independently, and some runtimes have historically cached forever regardless. It carries no weights, no metadata and no explicit failure signal, and negative caching means a record removed and re-added may be unreachable for longer than either change suggests. A short TTL mitigates some of this and multiplies query load.
None of this makes DNS the wrong choice — for stable, coarse-grained endpoints it is exactly right, and the operational simplicity is worth a great deal. It is the wrong choice for fast-changing sets of instances, which is why platforms that autoscale aggressively layer something else on top and use DNS only to find *that*.
Key points
- A discovery answer is a past-tense belief, not a fact about reachability.
- Staleness accumulates across four layers — instance-to-registry, within the registry, registry-to-client, and inside the client — and the total is usually minutes, not seconds.
- The registry has its own consistency model: a consistent one can take the whole fleet down with it, an available one hands out addresses of dead instances.
- Fail static. A client that cannot reach the registry must keep using its last known good set, never an empty one.
- The registry supplies candidates; only the client’s own request outcomes tell it which candidates work right now.
The chain, answered
Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.
- • An instance registers itself at startup with an address, metadata and a TTL, or is registered by a platform controller that observes it.
- • It renews the registration on a heartbeat interval; the registry evicts entries whose TTL lapses.
- • On shutdown it deregisters explicitly — the only unambiguous signal in the whole system, and the one most often skipped.
- • The registry replicates the entry to its peers according to its own consistency model.
- • Clients fetch the set by poll or watch and cache it locally, with a bounded lifetime.
- • Clients maintain per-endpoint health from observed request outcomes and eject failing endpoints locally, independent of the registry.
- • An instance dies without deregistering, and stays listed until its TTL lapses.
- • A heartbeat is dropped by a congested network and a healthy instance is evicted.
- • The registry is partitioned and each side evicts the other side’s instances.
- • A client’s watch silently dies and it serves from a cache that will never update again.
- • A resolver or connection pool ignores the TTL and holds a dead address indefinitely.
- • A shared-dependency outage makes every instance fail its health check at once, and the entire service is evicted.
- • Deploy-shaped error curve: connection-refused errors spike when instances are replaced and decay along a curve whose shape matches the client cache TTL rather than the deploy duration. The operator sees errors continuing long after the deploy is "finished".
- • Registry outage becomes fleet outage: every service logs "no healthy upstream" while every instance is running and every network path works. The operator sees a total outage with no unhealthy component.
- • Split registry: two halves each publish only their own instances, so capacity silently halves on each side while both report healthy. The operator sees latency and saturation rise with no drop in instance count on either dashboard.
- • Dead watch: one client fleet keeps routing to instances retired an hour ago while every other fleet is fine. The operator sees errors isolated to one deployment with no shared cause.
- • Mass eviction from a deep health check: a downstream database blips, every instance reports unhealthy, and the service is removed from discovery entirely — turning a degraded dependency into a hard outage.
- • Zombie instance: a process that answers health checks but cannot serve requests stays in the registry indefinitely, absorbing its share of traffic and failing all of it.
- • Registration and eviction are writes to shared state; how much coordination they need is exactly the CP/AP choice for the registry.
- • Reads are the overwhelming majority of registry traffic and are the part you must keep cheap and always-available — which argues for aggressive client caching and for never treating a read failure as fatal.
- • No coordination is needed for client-side health decisions, and that is what makes them fast. Each client ejects endpoints from its own pool based on its own observations.
- • Note the asymmetry to design for: agreement on membership is valuable but slow; local observation is fast and unshared. Use the first for the candidate set and the second for the routing decision (Cluster Membership: A Belief, Not a Fact makes the same distinction for cluster nodes).
- • With fail-static clients, a registry outage leaves traffic flowing on the last known topology — degrading only as instances change.
- • Under partition, each side routes within its own view; requests that must cross the partition fail regardless of what discovery says.
- • A stale entry costs a connection failure and a retry, which is why stale is a far cheaper failure than empty.
- • Newly added capacity is invisible during a registry outage, so the system cannot scale out until discovery recovers — an availability limitation rather than a correctness one.
- • Detect: export a staleness gauge from every discovery client — time since the last successful update. It is the only metric that catches a dead watch.
- • Contain: keep serving from cache and eject failing endpoints locally; never let a discovery failure propagate as a request failure.
- • Recover: on registry restoration, re-resolve with jitter. Thousands of clients reconnecting simultaneously is a thundering herd against a component that has just come back (Without Jitter, Every Client That Failed Together Retries Together).
- • Reconcile: compare the registry’s set against the platform’s actual running instances on a schedule; the difference is your ghost-entry and missing-entry count.
- • Verify: confirm that clients have converged — the staleness gauge across the fleet is the fastest way to see it.
- • Discovery staleness per client: seconds since last successful refresh. Alert on the fleet maximum, not the average.
- • Registered instance count versus actual running instance count. A persistent gap in either direction is a ghost or a missing registration.
- • Connection failures to discovered endpoints, separated from application errors. This is the direct measurement of how wrong the registry is.
- • Local ejection rate per client — how often clients are overriding the registry’s opinion. A rising rate is an early failure signal for the service, not for discovery.
- • Registry read and write latency and availability, treated as a tier-zero dependency, because that is what it is.
- • Count of instances that deregistered gracefully versus those that lapsed by TTL. The second number is your unnecessary-error budget.
- • Fleets whose instances change often — autoscaling, rolling deploys, spot capacity — where static configuration cannot keep up.
- • Environments with dynamic addressing, where an instance has no stable name.
- • When you want routing metadata beyond an address: version, zone, weight, capabilities.
- • When client-side load balancing is desirable, since it needs the full candidate set rather than one virtual address.
- • For a small number of stable endpoints, where DNS or a config file is simpler and has fewer failure modes.
- • When it becomes a hard dependency in the request path without a fail-static fallback — you have added a single point of failure in front of everything.
- • When teams treat the registry as authoritative health and skip client-side ejection, so recovery is bounded by TTLs.
- • Across trust boundaries, where an entry that routes traffic somewhere is a security-relevant claim that needs to be authenticated, not merely present.
- • DNS with a short TTL: no client library, universally supported, no health signal or weighting, and TTLs that are widely ignored.
- • A load balancer with a virtual IP: the client needs to discover only one stable address and the LB owns health checking — one hop more, and one fewer distributed system to operate.
- • Platform-managed discovery (Kubernetes Services, service mesh): the control plane already knows what is running, so registration is observation rather than self-report — which removes the "process is up but not registered" failure entirely.
- • Static configuration with a deploy-time update, for small fixed topologies. Boring, verifiable, and correct until something moves.
- • Consistent-hash or partition-map routing where the destination is a function of the key rather than a lookup (The Ring: Keeping the Mapping Stable When Membership Changes) — no registry in the request path at all.
The discovery staleness budget
What people believe, and what is true
The registry knows which instances are alive.
It knows which instances have recently sent it a message. That is a statement about the past and about the network, not about the instance.
A strongly consistent registry is safer.
It is more correct and less available, and discovery unavailability takes down every service at once. Many production registries deliberately choose stale answers over no answers.
If discovery fails, the client should return an error.
It should return the last known good set. Failing closed turns a control-plane blip into a data-plane outage, which is strictly worse in every scenario.
Shortening the TTL fixes stale routing.
It shrinks one of four terms, multiplies registry load, and increases the rate of false evictions during network hiccups. The other three terms — especially connection reuse — usually dominate.
Health checks should verify the whole request path including dependencies.
A check that fails on a shared dependency fails on every instance simultaneously and evicts the entire service. Health checks answer "can this instance serve", not "is everything fine".
Go deeper
Only the levels this lesson can honestly fill — a missing level is a claim nobody had.
Overview
A registry tells you where a service was, as far as it knew, a while ago. Treat every entry as a hint and verify by connecting.
Practical
Compute your true staleness by adding all four layers, then fix the largest term — usually connection reuse or client cache, not the health-check interval. Make the discovery client fail static, persist the last good set, and add per-endpoint ejection based on observed errors.
Advanced
Decide the registry’s consistency model deliberately. If it is consistent, a registry outage is a fleet outage, so client caching is not an optimisation but the availability design. If it is available, plan for ghost entries and make the client’s own health signal the real routing input.
Internals
When a registry loses heartbeats from a large fraction of instances at once, the likeliest cause is the registry’s own network, not simultaneous mass death. Eureka encodes this as self-preservation: above a threshold of missed renewals it stops evicting entirely. Any failure detector consumed by a component that can act destructively — evict, fail over, rebalance — needs the same guard, because the detector and the thing it monitors share a network.
Apply it
- 🔧 Measure your real staleness: terminate an instance without deregistration and record how long it keeps receiving requests. Compare with your estimate.
- 🔧 Add a staleness gauge to one discovery client and alert on it. It is the metric that catches a silently dead watch, and almost nobody has it.
- ⚡ After a network blip between availability zones, both halves of the registry evict the other half. Each side has half the capacity and is overloaded. What design would have prevented this and what would it have cost?
- ⚡ A team reports that 0.3% of requests fail immediately after every deploy, always with connection refused, always decaying over about two minutes. Diagnose it and name the two changes that fix it.
- 💬 Your registry goes down. Should your clients keep routing traffic? Defend the answer.
- 💬 An instance is terminated. Trace every layer of caching between that event and the last request it receives, and total the time.
- 💬 Why is a health check that verifies the database connection a bad idea for a load balancer?