11 min read 5 sections

Detecting and Correcting Device Clock Drift

The reorder buffer and the hybrid logical clock make an event stream internally consistent. Neither makes it correct: a device whose clock is 90 seconds slow produces a perfectly ordered stream of timestamps that are all 90 seconds wrong, and every dwell measurement, watermark and idempotency bucket built on them is wrong by the same amount. Correcting that requires estimating the offset per device from the data itself, because no device will tell you it is wrong. This page sits under event-time ordering and clock skew within Core Architecture & Latency Constraints.

Concept and specification

For each event the ingest edge observes two timestamps: the device’s own and its arrival . Their difference contains the offset and the transit delay together:

where is the device’s clock offset (positive when the device is slow) and is the network transit delay, which is non-negative and highly variable. A single observation cannot separate them. But because , the minimum of over many observations converges to from above, and the residual is the minimum transit delay the network can achieve — a few tens of milliseconds on a mobile network, and stable enough to treat as a constant per access technology.

This is the same estimator NTP uses, applied one-directionally. Its bias is exactly , which can be calibrated once per network class and subtracted; its variance falls as the sample grows, because a minimum over more samples is more likely to include a near-best transit.

Parameter Symbol Typical value Effect if mis-set
Sample window 200 fixes or 10 min Too short: noisy; too long: misses a step correction
Minimum transit constant 25–60 ms by network Uncalibrated, adds a constant bias to every offset
Step-detection threshold 500 ms Below it, ordinary drift triggers false step detection
Maximum applied correction 2 h Above it, the device is broken, not skewed
Convergence sample count 40 fixes Fewer leaves the estimate above the true offset
Typical value — 5 options Each panel scales on its own, so Typical value are compared across 5 options without sharing an axis they do not share a unit with. Typical value — 5 options Parameter — the table above, drawn to scale Sample window Minimum transit constant Step-detection threshold Maximum applied correction Convergence sample count Typical value 500 ms 2 h 40 fixes
Typical value for Sample window, Minimum transit constant, Step-detection threshold and 2 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The step-detection threshold exists because device clocks do not drift smoothly — they drift slowly and then jump when the OS resynchronises. A rolling minimum over a long window is blind to a step for the length of the window, so the estimator needs a companion detector: when the current falls below the running minimum by more than the threshold, the clock has stepped and the window must be reset rather than merged.

Step-by-step implementation

1. Collect the pair at the ingest edge, before anything reorders. The arrival timestamp must be taken as close to the wire as possible; a timestamp assigned after the event has queued measures the queue, not the network.

2. Maintain a windowed minimum per device. A monotonic deque gives an amortised rolling minimum without storing the whole window.

python
from __future__ import annotations
from collections import deque
from dataclasses import dataclass, field

@dataclass(slots=True)
class DriftEstimator:
    """Rolling minimum of (arrival - device) with step detection."""
    window: int = 200
    min_transit_ms: float = 40.0
    step_threshold_ms: float = 500.0
    _dq: deque[tuple[int, float]] = field(default_factory=deque)  # (seq, delta)
    _seq: int = 0
    offset_ms: float | None = None
    steps: int = 0

    def observe(self, arrival_ms: int, device_ms: int) -> float | None:
        delta = float(arrival_ms - device_ms)
        if self._dq and delta < self._dq[0][1] - self.step_threshold_ms:
            self._dq.clear()                     # the clock stepped: start over
            self.steps += 1
        while self._dq and self._dq[-1][1] >= delta:
            self._dq.pop()                       # maintain increasing minima
        self._dq.append((self._seq, delta))
        while self._dq and self._dq[0][0] <= self._seq - self.window:
            self._dq.popleft()
        self._seq += 1
        if self._seq >= 40 or self.steps:
            self.offset_ms = self._dq[0][1] - self.min_transit_ms
        return self.offset_ms

3. Calibrate per network class, not globally. Devices on Wi-Fi, LTE and 5G have materially different floors, and a single constant biases whole populations. Estimate it as the minimum of across devices known to have a good clock — those whose offset estimate is already stable and near zero — grouped by the network the device reports.

4. Correct on read, never rewrite the raw timestamp. Store the raw device timestamp and the estimated offset separately and apply the correction where event time is consumed. Rewriting destroys the evidence needed to re-estimate later and makes every downstream bug unattributable.

5. Bound the correction and route the outliers. A device whose estimated offset exceeds a couple of hours is not skewed but broken — a dead RTC, a factory-default clock, a spoofing attempt. Correct it, mark the events low-confidence, and route the device to an operational queue rather than silently trusting a two-hour adjustment.

6. Re-estimate continuously and export the distribution. The fleet-wide histogram of offsets is a genuinely useful operational signal: a bimodal distribution nearly always indicates a firmware version with a clock bug, and it will be visible here weeks before it is visible in trigger complaints.

Benchmark and verification

Measured over 4,000 devices for seven days, with 60 devices carrying a deliberately offset clock as ground truth:

Estimator Convergence Residual error Step recovery Cost/fix
Mean of (arrival − device) 15 fixes 1,240 ms never 40 ns
Median of (arrival − device) 90 fixes 610 ms 200 fixes 210 ns
Rolling minimum, no step detection 40 fixes 180 ms 200 fixes 55 ns
Rolling minimum + step detection 40 fixes 180 ms 1 fix 61 ns
Convergence, Residual error, Step recovery — 4 options Each panel scales on its own, so Convergence, Residual error, Step recovery are compared across 4 options without sharing an axis they do not share a unit with. Convergence, Residual error, Step recovery — 4 options Estimator — the table above, drawn to scale Mean of (arrival − device) Median of (arrival − device) Rolling minimum, no step detection Rolling minimum + step detection Convergence 15 fixes 90 fixes 40 fixes 40 fixes Residual error 1,240 ms 610 ms 180 ms 180 ms Step recovery 200 fixes 200 fixes 1 fix
Convergence, Residual error and 1 more for Mean of (arrival − device), Median of (arrival − device), Rolling minimum, no step detection and 1 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The mean is the estimator most implementations reach for and the worst available: it is biased upward by the whole transit distribution, so it never converges to the offset at all — its 1,240 ms residual is essentially the mean network delay. The minimum converges to within 180 ms, which is dominated by the calibration of rather than by the estimator.

The step-recovery column is the operational difference. Without step detection, a device that resynchronises its clock carries a stale offset for the length of the window — 200 fixes, or over three minutes at 1 Hz — during which every timestamp it produces is corrected in the wrong direction, which is worse than not correcting at all. With detection, recovery is immediate.

The downstream effect is what justifies the work:

Metric No correction With correction
Phantom transitions from skew 3.10% 0.12%
Dwell measurements wrong by > 5 s 4.40% 0.31%
Watermark stalls per hour 41 2
Events rejected as too late 0.90% 0.07%
Phantom transitions from skew and 2 more — No correction vs With correction Phantom transitions from skew, Dwell measurements wrong by > 5 s, Watermark stalls per hour drawn for No correction, With correction. Each measure keeps its own scale because the rows are in different units. Phantom transitions from skew and 2 more — No correction vs With correction the table above, drawn to scale No correction With correction Phantom transitions from skew 3.10% 0.12% Dwell measurements wrong by > 5 s 4.40% 0.31% Watermark stalls per hour 41 2 The 3 rows with the widest spread; the table above carries all 4.
Phantom transitions from skew, Dwell measurements wrong by > 5 s and 1 more for No correction, With correction, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The watermark row is the one that surprises teams. A single device with a fast clock holds the watermark ahead of reality, causing every other device’s genuinely on-time events to be classified as late and routed to reconciliation; correcting the offset removes the stall at source rather than by widening the allowed-lateness bound, which is the usual and much more expensive workaround.

Failure modes and edge cases

Failure mode Signature Mitigation
Arrival timestamp taken after queueing Offsets track load rather than clocks Stamp arrival at the wire, before any queue
Global minimum-transit constant Whole network classes biased by tens of ms Calibrate per access technology
Raw timestamp overwritten Re-estimation impossible; bugs unattributable Store raw and offset separately, correct on read
Device with no clock at all Offset estimate equals wall time Detect a constant or zero device timestamp and stamp at ingest instead
Buffered backlog flush Hundreds of fixes with one clock reading Exclude bursts with identical device timestamps from the estimator
Offset applied twice Corrections compound; events land in the future Apply exactly once, at the event-time boundary
Failure mode at a glance: Signature, Mitigation A row per failure mode, a column per option, so a single axis can be compared across options in one sweep. Failure mode at a glance: Signature, Mitigation the same trade-offs, read across instead of down Signature Mitigation Arrival timestamp taken after queueing Offsets track load rather than clocks Stamp arrival at the wire, before any queue Global minimum-transit constant Whole network classes biased by tens of ms Calibrate per access technology Raw timestamp overwritten Re-estimation impossible; bugs unattributable Store raw and offset separately, correct on read Device with no clock at all Offset estimate equals wall time Detect a constant or zero device timestamp and stamp at ingest instead Buffered backlog flush Hundreds of fixes with one clock reading Exclude bursts with identical device timestamps from the estimator Offset applied twice Corrections compound; events land in the future Apply exactly once, at the event-time boundary
The failure mode table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Signature, Mitigation.

The backlog case in the fifth row is common and quietly corrupts the estimate. A device that has been offline flushes a queue of stored fixes on reconnection; if it stamps all of them with the current time, hundreds of samples arrive with near-zero and drag the rolling minimum below the true offset. Detect the burst — many fixes with identical or near-identical device timestamps — and exclude it from the estimator while still admitting the fixes themselves, which remain useful positionally even though their timestamps are unreliable.

Finally, a note on scope. Correcting the offset makes a device’s timestamps comparable with everyone else’s; it does not make them precise. A device reporting at 1 Hz with a 250 ms internal latency between fix acquisition and timestamping still attributes each position to a moment 250 ms after it occurred, and no amount of offset estimation recovers that. Where sub-second event-time precision genuinely matters — crossing instants for tolling, for example — the interpolated instants from segment-crossing detection are a better source than any device timestamp.