A shorter timeout makes a failure detector wrong more often
Simulate a modelNeeds Python
Problem
Across a network, a dead node and a slow one look the same: both stop answering. A failure detector can only wait, and a timeout is a guess. Wait too briefly and healthy but slow nodes are declared dead, which triggers elections and failovers for nothing; wait too long and a real crash goes unnoticed. Because network delays have fat tails, any fixed timeout is either slow or wrong. What a timeout can never do, in a correct protocol, is make a decision wrong: safety comes from counting quorum members and epochs, liveness from timeouts.
Measure the false-suspicion rate of a heartbeat detector against its timeout (The arithmetic worth carrying, Failure detection: liveness is a relation, not an attribute).
Simulate a node that sends a heartbeat every 100 ms. The network delay is random: 95% of the time 1-20 ms, 5% of the time 100-700 ms (a stand-in for pauses and congestion). The monitor suspects the node when no heartbeat arrives within the timeout.
The script tries timeouts from 150 ms to 10 s, and for each also crashes the node at a random moment to time the detection.
The question. How often does the detector wrongly suspect a healthy node at each timeout, and how long does it take to notice a real crash?
Predict
At 150-500 ms a large share of heartbeats trigger a false suspicion, and the rate falls steeply as the timeout grows past the largest delay. Then add a real crash at a random time and record the detection time, which equals roughly the timeout.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
a-shorter-timeout-makes-a-failure.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """A shorter timeout makes a failure detector wrong more often. A node heartbeats every 100 ms over a network that is usually fast and sometimes very slow. The monitor suspects the node whenever no heartbeat has arrived within the timeout. Measured: false suspicions per heartbeat for a range of timeouts, and the time to detect a real crash. """ import argparse, json, random p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--interval-ms", type=float, default=100, help="Heartbeat interval.") p.add_argument("--slow-share", type=float, default=0.05, help="Share of heartbeats delayed 100-700 ms (the rest take 1-20 ms).") p.add_argument("--timeouts", type=lambda s: [float(v) for v in s.split(",")], default=[150, 200, 300, 500, 700, 1000, 3000, 10000], help="Timeouts to try, in ms, comma-separated.") p.add_argument("--beats", type=int, default=100_000, help="Heartbeats per timeout.") p.add_argument("--crashes", type=int, default=1000, help="Simulated crashes per timeout, for the detection time.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) def delay(): return random.uniform(1, 20) if random.random() >= args.slow_share else random.uniform(100, 700) def false_suspicions(timeout): last_arrival, count = 0.0, 0 for i in range(1, args.beats + 1): arrival = i * args.interval_ms + delay() if arrival - last_arrival > timeout: count += 1 last_arrival = max(last_arrival, arrival) return count / args.beats def detection_time(timeout): """The node crashes at a random moment; the monitor notices `timeout` after the last heartbeat arrived.""" total = 0.0 for _ in range(args.crashes): crash = random.uniform(0, args.interval_ms) # time since the last heartbeat was sent last_arrival = delay() # that heartbeat's arrival, relative to its send total += max(last_arrival + timeout - crash, 0) return total / args.crashes rows = [] for t in args.timeouts: fs, det = false_suspicions(t), detection_time(t) rows.append({"timeout_ms": t, "false_suspicion_pct": fs * 100, "detect_ms": det}) print(f"timeout {t:>6.0f} ms: false suspicions {fs:8.3%} of heartbeats real crash detected after {det:7.0f} ms") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"timeouts": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run a-shorter-timeout-makes-a-failure.py uv run a-shorter-timeout-makes-a-failure.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --interval-ms100 | Heartbeat interval. |
| --slow-share0.05 | Share of heartbeats delayed 100-700 ms (the rest take 1-20 ms). |
| --timeouts150, 200, 300, 500, 700, 1000, 3000, 10,000 | Timeouts to try, in ms, comma-separated. |
| --beats100,000 | Heartbeats per timeout. |
| --crashes1000 | Simulated crashes per timeout, for the detection time. |
| --seed0 | Random seed. |
Our run
Data table
| Timeout (ms) | Heartbeats that trigger a false suspicion |
|---|---|
| 150 | 4.62% |
| 200 | 4.55% |
| 300 | 3.65% |
| 500 | 2.05% |
| 700 | 0.603% |
| 1,000 | 0% |
| 3,000 | 0% |
| 10K | 0% |
Data table
| Timeout (ms) | Detection time (ms) |
|---|---|
| 150 | 131 |
| 200 | 177 |
| 300 | 282 |
| 500 | 475 |
| 700 | 684 |
| 1,000 | 980 |
| 3,000 | 2,980 |
| 10K | 9,980 |
timeout 150 ms: false suspicions 4.623% of heartbeats real crash detected after 131 ms
timeout 200 ms: false suspicions 4.553% of heartbeats real crash detected after 177 ms
timeout 300 ms: false suspicions 3.645% of heartbeats real crash detected after 282 ms
timeout 500 ms: false suspicions 2.047% of heartbeats real crash detected after 475 ms
timeout 700 ms: false suspicions 0.603% of heartbeats real crash detected after 684 ms
timeout 1000 ms: false suspicions 0.000% of heartbeats real crash detected after 980 ms
timeout 3000 ms: false suspicions 0.000% of heartbeats real crash detected after 2978 ms
timeout 10000 ms: false suspicions 0.000% of heartbeats real crash detected after 9977 msWhat we found
At a 150 ms timeout, 4.62% of heartbeats triggered a false suspicion, nearly every slow heartbeat; at 500 ms it was still 2.05%. The rate reached zero only at 1,000 ms (0%), once the timeout exceeded the slowest delay plus the heartbeat interval. Detection of a real crash tracked the timeout closely: 131 ms at a 150 ms timeout, 9,980 ms at 10 s. Every millisecond removed from detection is paid for in false suspicions, as expected.
Read it
You now have both sides of the trade: speed of detection against false positives. The timeout changes liveness only; no value of it can make a decision wrong if the safety rule uses epochs.
The false-suspicion rate is the probability that a delay exceeds the timeout, the tail of the distribution, which tuning cannot shrink. Shortening the timeout to fail over faster (500 ms instead of 10 s) turns every GC pause and congested moment into a spurious election: the system does not become incorrect, it becomes unavailable. Adaptive detectors such as phi-accrual do not escape the trade; they let each caller choose its own point on it.