---
title: 'Health Endpoint Monitoring'
source: 'https://academia.sh/en/courses/resilience-patterns/health-endpoint-monitoring'
course: 'Resilience and Reliability'
language: en
updated: '2026-08-23T07:01:32+00:00'
license: 'CC BY-SA 4.0'
---

# Health Endpoint Monitoring

Designing the content of the health endpoint that feeds failover: measuring the shallow check, readiness check, and deep check in the same failure schedule, how adding a shared dependency to the check evicts every replica from the pool at once, the check interval's trade-off between detection delay and false eviction, and counting the load the check adds to the dependency.

Every decision in the previous lesson rested on a single input: the yes or no the health check
returns. How the threshold was chosen, when failback happened, even whether failover happened at
all depended on this one bit's accuracy. In the model's half-failed window, 3,888.80 writes were
lost while the system looked healthy — because the check only asked "are you responding".

The health check that feeds failover and the load balancer pool was established in the Relational
Database Administration and The Traffic Layer courses; the endpoint's form on the serving side was
introduced in the Server-Side Fundamentals course. This lesson designs that endpoint's
**content**: what question it asks, whether it puts its dependencies in scope, and how often it
asks. Where its collected metrics go, the dashboard, and the alert threshold are not this course's
subject; they belong to the Performance Anti-Patterns and Monitoring course.

## The Four Questions the Endpoint Can Ask

**The shallow check** only tests that the process is up and can respond. It produces its answer
without touching any dependency, so it is cheap and never gives a false negative.

**The readiness check** is a separate question: the process may be up but not ready to take
traffic. A newly started replica may not have filled its cache, read its configuration, or
finished its migration. Being up and being ready are two separate states, and merging them into
one endpoint loses one of the two.

**The deep check** touches the replica's dependencies. Two kinds of dependency are separated here,
and the separation is this lesson's main result: a dependency on the replica's **own path**
(whether this replica can write to the state store) versus a **shared** dependency (whether the
state store itself is up). The first differs between replicas, the second is the same for all of
them.

The setup below is a **model**: three replicas, one shared state store, and a billing store that
feeds only the batch flow. A round is an abstract step. The request mix per round is chosen close
to K01's read–write ratio: 8 reads, 2 writes, 1 batch.

```js
// health/model.mjs — the content of the health endpoint is a MODEL. Three replicas, one shared
// state store, and a store used only by the batch feed. A round is an abstract step. The request
// mix per round is chosen close to K01's read/write ratio: 8 reads, 2 writes, 1 batch.
export const ROUND = 130;
export const K3_STOPPED = 82;   // k3 stops in this round; detection delay is measured from here
export const REPLICA = ["k1", "k2", "k3"];
const P = (t, a) => a.some(([b, c]) => t >= b && t <= c);

export const schedule = {
  halfFailed: { k1: [[20, 45]], k2: [], k3: [] },       // looks like it is writing, never reaches the store
  warming: { k1: [], k2: [[1, 8], [60, 66]], k3: [] },  // process is up, not ready for traffic
  stopped: { k1: [], k2: [], k3: [[82, 95]] },          // process does not respond
  brief: { k1: [], k2: [], k3: [[50, 50], [58, 59]] },  // brief unresponsiveness, recovers on its own
  stateStore: [[100, 110]],                             // shared dependency goes down
  billingStore: [[115, 125]],                           // affects only the batch feed
};

export const stateStoreUp = (t) => !P(t, schedule.stateStore);
export const billingStoreUp = (t) => !P(t, schedule.billingStore);
export const processUp = (k, t) => !P(t, schedule.stopped[k]) && !P(t, schedule.brief[k]);
export const warming = (k, t) => P(t, schedule.warming[k]);
export const pathHealthy = (k, t) => !P(t, schedule.halfFailed[k]);   // the replica's own write path
export const canRead = (k, t) => processUp(k, t) && !warming(k, t);

// Five endpoint contents. Each returns a single yes/no per replica.
export const ENDPOINT = {
  shallow: (k, t) => processUp(k, t),
  readiness: (k, t) => canRead(k, t),
  deepLocal: (k, t) => canRead(k, t) && pathHealthy(k, t),
  deepShared: (k, t) => canRead(k, t) && pathHealthy(k, t) && stateStoreUp(t),
  deepAll: (k, t) => canRead(k, t) && pathHealthy(k, t) && stateStoreUp(t)
    && billingStoreUp(t),
};

const READS = 8, WRITES = 2, BATCH = 1;   // requests per round: close to K01's read/write ratio

export function run(endpointName, { interval = 1, round: N = ROUND } = {}) {
  const endpoint = ENDPOINT[endpointName];
  const s = { outOfPoolRounds: 0, droppedReads: 0, droppedWrites: 0, droppedBatch: 0, silent: 0,
    wastedReject: 0, checks: 0, falseEviction: 0, detection: null };
  let round = 0, decision = Object.fromEntries(REPLICA.map((k) => [k, true])), previousDecision = { ...decision };

  for (let t = 1; t <= N; t += 1) {
    if ((t - 1) % interval === 0) {                     // check round
      previousDecision = { ...decision };
      for (const k of REPLICA) decision[k] = endpoint(k, t);
      s.checks += REPLICA.length;
      for (const k of REPLICA)                          // did a brief outage evict it from the pool
        if (previousDecision[k] && decision[k] === false && P(t, schedule.brief[k])) s.falseEviction += 1;
      if (s.detection === null && decision.k3 === false && P(t, schedule.stopped.k3))
        s.detection = t - K3_STOPPED;
    }
    const pool = REPLICA.filter((k) => decision[k]);
    s.outOfPoolRounds += REPLICA.length - pool.length;
    const someCanRead = REPLICA.some((k) => canRead(k, t));

    for (let i = 0; i < READS; i += 1) {
      if (pool.length === 0) { s.droppedReads += 1; if (someCanRead) s.wastedReject += 1; continue; }
      const k = pool[round++ % pool.length];
      if (canRead(k, t) === false) s.droppedReads += 1;
    }
    for (let i = 0; i < WRITES; i += 1) {
      if (pool.length === 0) { s.droppedWrites += 1; continue; }
      const k = pool[round++ % pool.length];
      if (canRead(k, t) === false) { s.droppedWrites += 1; continue; }
      if (pathHealthy(k, t) === false) { s.silent += 1; continue; }   // accepted, never reached the store
      if (stateStoreUp(t) === false) s.droppedWrites += 1;
    }
    for (let i = 0; i < BATCH; i += 1) {
      if (pool.length === 0 || billingStoreUp(t) === false) { s.droppedBatch += 1; continue; }
      round += 1;
    }
  }
  return s;
}
```

```js
// health/measure.mjs — five endpoint contents in the same failure schedule, then the effect of the check interval
import { run, ROUND, REPLICA } from "./model.mjs";

const s = (x, n) => String(x).padStart(n);
console.log(`${ROUND} rounds, ${REPLICA.length} replicas, 8 reads + 2 writes + 1 batch request per round.`);
console.log("schedule: k1 20-45 half-failed, k2 1-8 and 60-66 warming, k3 50 and 58-59 brief " +
  "unresponsive, 82-95 stopped, state store 100-110, billing store 115-125 down.");
console.log();
console.log("endpoint content | out of pool | dropped reads | dropped writes | dropped batch | silent lost update | wasted reject");
console.log("-----------------|-------------|----------------|-----------------|----------------|---------------------|--------------");
for (const name of ["shallow", "readiness", "deepLocal", "deepShared", "deepAll"]) {
  const r = run(name);
  console.log(`${name.padEnd(17)} | ${s(r.outOfPoolRounds, 11)} | ${s(r.droppedReads, 14)} | ` +
    `${s(r.droppedWrites, 15)} | ${s(r.droppedBatch, 14)} | ${s(r.silent, 19)} | ${s(r.wastedReject, 12)}`);
}

console.log();
console.log("interval | checks | detection delay | false eviction | dropped reads | silent lost update");
console.log("---------|--------|------------------|-----------------|----------------|--------------------");
for (const interval of [1, 2, 4, 8]) {
  const r = run("deepLocal", { interval });
  console.log(`${s(interval, 8)} | ${s(r.checks, 6)} | ${s(`${r.detection} round`, 16)} | ` +
    `${s(r.falseEviction, 15)} | ${s(r.droppedReads, 14)} | ${s(r.silent, 19)}`);
}
```

```
130 rounds, 3 replicas, 8 reads + 2 writes + 1 batch request per round.
schedule: k1 20-45 half-failed, k2 1-8 and 60-66 warming, k3 50 and 58-59 brief unresponsive, 82-95 stopped, state store 100-110, billing store 115-125 down.

endpoint content | out of pool | dropped reads | dropped writes | dropped batch | silent lost update | wasted reject
-----------------|-------------|----------------|-----------------|----------------|---------------------|--------------
shallow           |          17 |             40 |              32 |             11 |                  17 |            0
readiness         |          32 |              0 |              22 |             11 |                  17 |            0
deepLocal         |          58 |              0 |              22 |             11 |                   0 |            0
deepShared        |          91 |             88 |              22 |             22 |                   0 |           88
deepAll           |         124 |            176 |              44 |             22 |                   0 |          176

interval | checks | detection delay | false eviction | dropped reads | silent lost update
---------|--------|------------------|-----------------|----------------|--------------------
       1 |    390 |          0 round |               2 |              0 |                   0
       2 |    195 |          1 round |               1 |             11 |                   0
       4 |     99 |          3 round |               0 |             19 |                   0
       8 |     51 |          7 round |               0 |             40 |                   3
```

## How Far Depth Should Go

The first three rows show what depth gains. The shallow check kept the warming replica in the
pool, dropping 40 reads and 32 writes routed to it; adding the readiness check brought dropped
reads to 0 and dropped writes to 22. The remaining 22 writes occur in the eleven rounds the shared
store is down, and no endpoint content rescues them — the replica may be healthy, but there is
nowhere to write.

The column's name comes from the Scaling the Data Layer course: a **silent lost update** is an
accepted write invalidated without ever telling its owner. There the cause was conflict; here it
is the half-failed replica — the measure is the same.

The local deep check removes it: 17 to 0. The half-failed replica is now out of the pool, because
the endpoint honestly answers "can I write". Its price shows in the out-of-pool column, 32 to 58 —
the answer to the previous lesson's unsolved problem, at the sole cost of removing the broken
replica from the pool.

**The fourth row is where the sign flips.** When the shared state store joins the check, the
moment it goes down all three replicas give the same answer and leave the pool at once. Once the
pool is empty, not only writes but **reads** are rejected too: 88 dropped reads, all wasted
rejections — a request rejected while at least one replica could have answered it, which is
exactly what that column means. The edge cache and the read-from-replica path were up; the check
shut them out too. Dropped batch work rose from 11 to 22 alongside.

The fifth row repeats the mistake. When the store feeding only end-of-day billing joins the check,
the replicas answering tracking queries also leave the pool once that store goes down: wasted
rejects rise from 88 to 176 — no link existed between the billing flow's store and the tracking
query's path, yet the check tied the two together.

The rule reads from these three rows: **the endpoint should ask whether the replica is worse off
than its neighbors, not whether the world is fine.** A condition that answers the same on every
replica, once placed in the endpoint, empties the pool the moment it breaks — the spread comes
from the check itself. This is why a shared dependency's failure is the circuit breaker's and
graceful degradation's subject, measured in the previous topic, not the health endpoint's.

## How Often the Interval Should Run

The second table runs the same endpoint content (`deepLocal`) at four check intervals. At interval
1, checks number 390, detection delay is 0 rounds, dropped reads 0 — but there are two **false
evictions**: brief self-recovering outages evicted the replica from the pool. At interval 4, false
evictions zero out, and in exchange the round `k3` stops is noticed 3 rounds later, dropping 19
reads routed to it in that span. At interval 8, delay rises to 7 rounds, dropped reads to 40, and
the half-failed replica, left uninspected between checks, produces 3 silent lost updates.

The trade-off has the same shape as the previous lesson's threshold trade-off but is a separate
axis: the threshold sets how many consecutive checks must fail, the interval sets how infrequent
checks are. The two multiply — interval 4 and threshold 3 mean a real failure can, in the worst
case, go unnoticed for 12 rounds.

## The Numbers for Two Days

The check's failure-free-day cost has two line items: the check load placed on the dependency, and
the requests false eviction shifts onto the remaining replicas. The first is the deep check's
direct bill.

```js
// health/budget.mjs — the check's failure-free-day cost: the check load it puts on the
// dependency and the requests false eviction shifts onto the remaining replicas
const ROUND_SEC = 2;       // KU1 (assumption): one round is one check interval, 2 seconds
const STORE_REQ = 138.89;  // K01: requests/s reaching the store
const PEAK_EDGE = 513.89;  // K01: peak requests/s at the edge
const BRIEF_MONTH = 20;    // KU3 (assumption): 20 transient unresponsive periods a month
const b = (x, n = 2) => x.toFixed(n);

console.log(`${"replicas".padStart(9)}${"interval".padStart(10)}${"checks/s".padStart(11)}` +
  `${"store load added".padStart(19)}${"checks/day".padStart(14)}`);
for (const [replicas, interval] of [[3, 1], [3, 4], [12, 1], [12, 4], [48, 1], [48, 4]]) {
  const sec = interval * ROUND_SEC, rate = replicas / sec;
  console.log(`${String(replicas).padStart(9)}${(sec + " sec").padStart(10)}${b(rate).padStart(11)}` +
    `${("%" + b((100 * rate) / STORE_REQ)).padStart(19)}${Math.round(rate * 86_400).toLocaleString("en-US").padStart(14)}`);
}

console.log(`\nfalse eviction: when one of 3 replicas leaves the pool, the remaining two each carry ` +
  `${b(PEAK_EDGE / 2)} instead of ${b(PEAK_EDGE / 3)} req/s (${b((PEAK_EDGE / 2) / (PEAK_EDGE / 3))}x)`);
for (const interval of [1, 2]) {
  const share = interval === 1 ? 2 / 3 : 1 / 3;   // from the model: how many of the 3 brief outages were caught
  console.log(`interval ${interval}: ${b(BRIEF_MONTH * share, 1)} false evictions a month, each cutting ` +
    `capacity by ${b(100 / 3, 1)}% for up to ${interval * ROUND_SEC} sec`);
}
```

```
 replicas  interval   checks/s   store load added    checks/day
        3     2 sec       1.50              %1.08       129,600
        3     8 sec       0.38              %0.27        32,400
       12     2 sec       6.00              %4.32       518,400
       12     8 sec       1.50              %1.08       129,600
       48     2 sec      24.00             %17.28     2,073,600
       48     8 sec       6.00              %4.32       518,400

false eviction: when one of 3 replicas leaves the pool, the remaining two each carry 256.94 instead of 171.30 req/s (1.50x)
interval 1: 13.3 false evictions a month, each cutting capacity by 33.3% for up to 2 sec
interval 2: 6.7 false evictions a month, each cutting capacity by 33.3% for up to 4 sec
```

With three replicas and a two-second interval, the deep check adds 1.50 queries a second to the
store — 1.08 percent of K01's 138.89 req/s reaching it. The number grows linearly with replica
count and runs into a limit there: at 48 replicas, the same interval means 17.28 percent added
load. **The deep check's bill scales with replica count, not workload** — a scaling decision also
changes the check policy. Multiplying the interval by four divides the bill by four.

The second line item is false eviction. When one replica leaves a three-replica pool, the
remaining two carry 256.94 req/s instead of 171.30, a 1.50x increase. At interval 1 the model
catches two-thirds of the false evictions; with 20 transient unresponsive periods a month assumed
(KU3, from the previous lesson), a third of capacity vanishes for two seconds 13.3 times a month.
At interval 2 the count drops to 6.7, but each event's window doubles — the failure-free day's
cost, paid with no failure at all.

**The failing day's gain** sits between the first table's `shallow` and `deepLocal` rows: in the
same failure schedule, 40 dropped reads fall to 0, 32 dropped writes to 22, 17 silent lost updates
to 0, and wasted rejects stay at 0. The same table also shows where the gain ends: one step too
much depth produces 88 wasted rejects, two steps too much produces 176.

## Summary

- The health endpoint is not one question: the shallow check tests that the process is up, the
  readiness check that it can take traffic, the deep check that the dependency path works.
- Adding the readiness check saved 40 reads and 10 writes routed to the warming replica; adding
  the local deep check brought silent lost updates from 17 to 0, at the cost of out-of-pool rounds
  rising from 32 to 58.
- Adding a shared dependency to the check produces spread: when the store went down all three
  replicas left the pool, and 88 reads were rejected while answerable; adding the flow-specific
  store too raised this to 176.
- The rule: the endpoint asks whether a replica is worse off than its neighbors; put a condition
  in it that answers the same on every replica, and the pool empties.
- The check interval is a separate axis: interval 1 gives 2 false evictions and 0 rounds of delay,
  interval 8 gives 0 false evictions but 7 rounds of delay, 40 dropped reads, and 3 silent lost
  updates.
- The failure-free day's cost is counted: at 3 replicas and a 2-second interval, the deep check
  adds 1.08 percent load to the store, 17.28 percent at 48 replicas; false eviction raises the
  remaining replicas' load to 1.50x, 13.3 times a month.

## Next Step

These two lessons counted how long a failure takes to notice and to hand over: threshold, interval,
promotion, and failback rounds — always the same quantity, **time to recovery**. But the writes
lost in the previous lesson's half-failed window are a different quantity neither lesson measured:
how much data is left behind when the system comes back? The two questions call for separate
thresholds, both paid from the same budget. The next lesson names these thresholds, ties them to
backup frequency and replication lag to turn them into numbers, and compares them against K01's
43.2-minute monthly budget.
