skip to content

Time-based replication-lag metrics can report zero or null while a replica is genuinely far behind. Why does that happen, and how would you measure lag so the number is trustworthy?

level: seniorimportance: should knowfreq 40%

answer

  1. Formula: now − timestamp of newest applied change
  2. Idle primary → no newest change → 0 or a fake spike
  3. MySQL: applier-only metric, drains to 0 when receiver dies
  4. NULL means stopped, not caught up — never coerce to 0
  5. Trust = primary-side bytes + thread state + heartbeat

basics

~20 s

Time-based lag is derived from the newest change record the replica applied. With no new records — an idle primary, or a dead receiver whose backlog has drained — there is nothing to compare against, so it reads zero or null. Trustworthy measurement combines byte distance from the primary, thread/connection state, and a heartbeat write.

solid answer

~60 s

Time lag is computed as *now minus the timestamp of the change the replica most recently applied (or is applying)*. That expression breaks in three ways: - **Idle primary.** No writes means the applier has nothing outstanding, so the metric reads 0 — even if the replication connection died an hour ago and thousands of writes would be waiting the moment traffic resumed. - **Drained backlog after a receive failure.** MySQL's `Seconds_Behind_Source` reflects the applier only. If the receiver stalls, the applier finishes the relay log and the field falls to 0 while the replica drifts. - **State, not number.** The same field reads NULL when replication threads are stopped. Dashboards that coerce NULL to 0 turn an outage into a green graph. Secondary distortions: clock skew between hosts, and chained topologies where event timestamps originate from the top-level primary. A trustworthy setup measures three things together: **byte distance observed from the primary's own view of the connection**, **replication connection/thread state as an explicit alert**, and a **heartbeat row** written on the primary every second and compared on the replica — which keeps a write always flowing and measures true end-to-end write-to-visible latency.

code

sql · 6 lines
sql
-- primary: an agent runs this once per second
UPDATE repl_heartbeat SET ts = now() WHERE id = 1;

-- replica: true write-to-visible latency across the whole pipeline
SELECT EXTRACT(EPOCH FROM (now() - ts)) AS lag_seconds
FROM repl_heartbeat WHERE id = 1;

go deeper

for a junior

Know that a zero lag reading is not proof of health and that byte-based lag and connection state must be checked too.

for a middle

Explain the formula and at least the idle-primary and stopped-thread cases, and know that NULL is a state rather than a number.

for a senior

Cover the receiver-dead-applier-drained case explicitly, and design the three-signal setup: primary-side bytes, state alerts, heartbeat.

for a principal

Frame it as monitoring doctrine — downstream-only metrics cannot detect upstream silence — and mandate the signal set plus alert-on-missing-data across the fleet.

## What the metric actually computes Almost every built-in "seconds behind" figure is the same expression: ``` lag_seconds = replica_clock_now - timestamp_of_newest_change_the_replica_has_applied ``` That is a perfectly good definition **when changes are flowing**. Its failure modes all come from the fact that it is defined in terms of *work the replica has seen*, not in terms of *work the primary has produced*. If the replica has seen nothing recently, the metric has nothing to say and — critically — says "zero" rather than "unknown". ## Blind spot 1: the idle or low-write primary Suppose the primary takes one write per hour. Immediately after that write is replayed, the applied timestamp is fresh and lag reads ~0. An hour later, on PostgreSQL, `now() - pg_last_xact_replay_timestamp()` reads ~3600 seconds — alarming, but entirely false: the replica is perfectly current, there simply has not been a transaction. MySQL's `Seconds_Behind_Source` handles the same situation the other way, reporting 0 because the applier has nothing queued. So on a low-write system, the same underlying situation produces a scary number on one engine and a reassuring one on the other, and **neither is measuring health**. Byte distance is the honest metric here: zero outstanding bytes means genuinely caught up. ## Blind spot 2: receiver dead, applier caught up This is the dangerous one because the number is reassuring while the system is broken. MySQL splits receive and apply into different threads. `Seconds_Behind_Source` is derived from the event the **applier** is executing. If the I/O thread dies — network partition, credentials expired, source binlog purged — the applier keeps working through whatever is already in the relay log, finishes it, and then reports 0. The replica is now frozen in time, serving increasingly stale reads, with a green lag graph. The general lesson: **a metric computed entirely on the downstream node cannot detect that the upstream stopped talking to it.** Detection requires either the primary's view of the connection (does the primary still see this replica connected, and at what position?) or a signal that must traverse the whole pipeline (a heartbeat). ## Blind spot 3: NULL treated as zero When replication threads are stopped or errored, MySQL reports `Seconds_Behind_Source` as NULL. Many collection scripts cast that to 0 (or drop the sample), so a completely stopped replica looks flawless. The correct handling is to alert on the **state** — threads not running, last error non-empty, replica not present in the primary's connected-replica list — as a separate, higher-severity condition than a numeric threshold. ## Blind spot 4: clocks and chains - **Clock skew.** The expression subtracts a timestamp produced on one host from the clock of another. A replica whose clock runs ahead reports inflated lag; behind, deflated (and in some builds, negative values appear). Without NTP discipline the metric drifts on its own. - **Chained replication.** In A → B → C, change records generally keep the timestamp assigned at A. C's time-behind therefore measures lag against A — the end-to-end number you usually want, but not what "behind my upstream" implies. Interpreting it as lag behind B misattributes the problem. - **Sampling.** A one-minute scrape interval cannot see a 20-second stall; short conflicts and lock waits vanish between samples. ## Building a trustworthy measurement Use three complementary signals — no single one is sufficient: **1. Byte distance from the primary's perspective.** On the primary, compare its current log position with each connected replica's reported position. This is immune to idleness (zero writes means zero outstanding bytes) and, because it enumerates connections, it exposes a replica that has vanished entirely — the row simply disappears. It also feeds retention safety: how close a replica is to falling off the retained log and needing a rebuild. **2. Explicit state alerts.** Replication threads/workers running; last error empty; replica present in the primary's list of connected replicas; replication slot (where used) active. These are boolean conditions, alerted independently of any numeric threshold, and they must be alerted on missing data too — a metric that stops arriving is itself a symptom. **3. A heartbeat.** An agent on the primary updates a single-row table with the current timestamp every second. The replica computes `now() - heartbeat_ts`. This: - guarantees writes always flow, so the time metric is always meaningful; - traverses the entire pipeline (network, receive, flush, apply), so a stalled receiver shows up immediately as a rising number rather than a comforting zero; - measures exactly what an application experiences — the delay between a committed write and its visibility on the replica — rather than an internal position. Its one requirement is clock discipline between the hosts, since it is still a cross-host subtraction; keep NTP running and monitor skew as its own metric. ## The rule of thumb Treat any downstream-only, time-based lag figure as *best-effort*, never as proof of health. Prove health with the primary's view plus a signal that must cross the wire; use the time figure for staleness budgets once you know the pipeline is alive.

  • On PostgreSQL, why can now() - pg_last_xact_replay_timestamp() report a large value on a healthy standby?
    That expression measures the age of the last replayed transaction, not outstanding work. If the primary has not committed a transaction for ten minutes, the standby has nothing newer to replay and the value climbs to ten minutes while the standby is perfectly current. Cross-check with the byte distance between the primary's current WAL position and the standby's replay position, which is zero in that situation.
  • Why is a heartbeat still not enough on its own?
    It gives one end-to-end number in seconds, but says nothing about backlog volume, so it cannot warn you that a replica is approaching the primary's log-retention limit and will need a full rebuild. It also depends on synchronized clocks and on the heartbeat agent itself staying alive — a dead agent looks identical to a dead replica. Pair it with primary-side byte distance and explicit thread/connection state.

saying these in an interview costs you the question

  • Reporting a replica healthy on the strength of a zero seconds-behind reading
  • Coercing NULL replication status to 0 in metric collection
  • Ignoring clock skew when the metric is a cross-host timestamp subtraction
  • Assuming a chained replica's time-behind is measured against its immediate upstream
  • Alerting only on thresholds and never on the absence of the metric itself

context