← back to journal

Half the Queue Lands on the Clock

2026-10-08 · Kit Wickham

Network time sync works by bouncing a packet off a server. The client notes when it sent the request (t1) and when the answer came back (t4); the server stamps when it received the request (t2) and when it replied (t3). If the trip there took as long as the trip back, the client's clock is off by ((t2 − t1) + (t3 − t4)) / 2. That "if" carries the whole method. When the two directions differ, the estimate is wrong by exactly half the difference, and nothing in the four timestamps can tell you. A fixed difference, like a longer route one way, is invisible for good.

Queues are a different matter, because they come and go. If your upload link is busy, every request waits in your router's buffer on the way out, the answer comes straight back, and half of that wait shows up as clock error. I wanted to know how big that gets, which of the usual filters notice it, and how much public time servers disagree when I ask them from one ordinary connection.

The short version: a link that's busy now and then costs well under a millisecond after NTP's standard filter, which keeps the fastest of the last 8 exchanges. A bulk upload that fills a bloated buffer for ten minutes is a different story: the standard filter left a mean error of 8 ms and a 95th percentile of 34 ms in my simulation, because 8 samples at 16-second intervals cover only two minutes. Two old fixes work: ntpd's huff-n'-puff filter (off by default) and simply ignoring exchanges that took much longer than the best one recently seen. When both directions get congested, huff-n'-puff made the bad cases worse than doing nothing, and only the second fix held up. From one ordinary connection, five stratum-1 servers, each keeping near-perfect time, put my clock at five different places spread over 9 ms.

The setup

The simulator sends one exchange every 16 seconds (some runs use 64) for 48 simulated hours, with the first 2 hours discarded. Each direction takes 10 ms plus a small random jitter (exponential, mean 0.2 ms), plus whatever queue is in the way. The client's clock wanders: its rate does a random walk of 0.1 parts per million per square root of an hour, a fairly well-behaved quartz clock. Every number below is the error against the true offset at the moment of the newest exchange, averaged over 20 seeds.

Two kinds of queue:

The estimators, all from the same stream of exchanges:

I also ran the median and mean of the last 8, a 64-sample shortest-round-trip filter, and a filter that takes the best outbound and best return legs from different exchanges. Those are in the table further down.

The simulator matches closed forms where I have them. With a busy upload queue and no jitter, a single exchange is off by half the mean wait on average: ρ/(2(1 − ρ)) ms, which is 2.00 ms at 80% utilisation; measured 2.01. The clock filter only fails when all 8 samples hit a queue; its expected error is ρ8/(16(1 − ρ)), 0.0524 ms at 80%; measured 0.0523 over 2,000 simulated hours. With equal random jitter both ways (mean 1 ms each) a single exchange's error is a Laplace variable with mean absolute value 0.5 ms; measured 0.50 to 0.505.

Two log-scale charts of mean offset error. Left: against upload utilisation from 0.3 to 0.95; a single exchange rises from 0.3 ms to 9.5 ms, the NTP clock filter stays under 0.1 ms up to 0.7 then rises to 0.9 ms, and huff-n-puff, slope fit and delay gate stay between 0.07 and 0.16 ms. Right: bulk transfers 30% of the time against typical transfer length from 2 minutes to 2 hours; single exchange about 12 ms throughout, the clock filter 4 to 10 ms, huff-n-puff, slope fit and gate around 0.1 ms for short transfers rising to about 3.5 ms at 2 hours. Dashed lines for congestion in both directions: huff-n-puff and slope fit stay at 1 to 10 ms, the gate stays low until 2 hours.

20 seeds per point, one exchange every 16 seconds. Right panel: solid lines have bulk uploads only (30% of the time); dashed lines add downloads running independently (uploads 20%, downloads 30% of the time).

A busy link is mostly fine

At 80% upload utilisation a single exchange is off by 2.0 ms on average and 7 ms at the 95th percentile. The clock filter brings that to 0.12 ms, about what any of the fancier filters manage. It only starts to slip at 90% and above (0.33 ms, then 0.87 ms at 95%), because a run of 8 samples that all hit the queue stops being rare. Huff-n'-puff, the slope fit and the gate stay at 0.13 to 0.16 ms there.

When both directions have independent short queues, picking the best outbound and best return legs separately beats the clock filter (0.16 vs 0.45 ms at 80%), because it doesn't need both directions to be empty on the same exchange. The gate is better still above 80%. Huff-n'-puff's one-sided assumption starts to cost: at 95% in both directions its 95th percentile was 9.4 ms against the clock filter's 7.3.

A long upload is not

A bulk transfer is the case the clock filter can't see. If the upload runs for ten minutes, every exchange in its two-minute window has the full queue, so its "best" sample is as bad as the rest. With transfers running 30% of the time and lasting 10 minutes on average:

offset error, ms (mean / 95th pct)uploads onlyuploads and downloadsuploads, clock wander 1 ppm/√h
single exchange12.0 / 46.715.8 / 47.312.0 / 46.7
median of 811.7 / 42.615.2 / 43.111.8 / 42.6
NTP clock filter (best of 8)8.0 / 33.611.2 / 34.68.1 / 33.7
best of 64 (17 minutes)1.9 / 22.63.6 / 30.43.1 / 23.2
best legs separately (8)8.0 / 33.711.0 / 34.28.1 / 33.8
huff-n'-puff0.09 / 0.315.4 / 54.40.22 / 0.62
slope fit0.11 / 0.459.9 / 39.10.56 / 3.1
delay gate0.13 / 0.410.19 / 0.690.61 / 3.2

One exchange every 16 s, 20 seeds, 100 ms of bufferbloat. "Uploads and downloads": uploads 20% and downloads 30% of the time, independently.

Three things stand out.

The cost of the gate is that it holds an old answer while the clock drifts. With my default wander of 0.1 ppm/√h that cost is invisible, but with a ten times worse clock (third column) the gate and the slope fit drift to 0.6 ms mean and 3 ms at the 95th percentile, while huff-n'-puff, which corrects each fresh sample instead of holding an old one, stays at 0.2. With a clock 30 times worse the gate's 95th percentile reached 9.6 ms. That's the real trade-off: huff-n'-puff for one-sided congestion and a poor clock, the gate when congestion can go either way and the clock is decent.

And the fixed part never goes away. Give the outbound path 12 ms and the return 8 ms, no queues at all, and every estimator I tried is off by 2.0 ms, forever, with no sign anything is wrong.

What real servers say

Then I asked real servers. I wrote a small SNTP client and queried ten public time services in rotation for 90 minutes, each about every 17 seconds: roughly 300 exchanges per service. Five of them are stratum 1, meaning they sit next to a reference clock (GPS or an atomic standard): Apple, Google, Meta, NIST and Germany's PTB. Each of those knows UTC to far better than a millisecond. To take my own clock's drift out of the picture, I split the run into 5-minute blocks, kept each server's fastest exchange per block, and compared it with the median of the five stratum-1 answers from the same block.

Left: scatter of offset shift divided by extra round trip against extra round trip on a log axis from 1 to 300 ms. Apple's slow answers, 38 to 282 ms extra, sit on the +0.5 line marked all extra delay outbound, with two on the -0.5 line. A cluster of Microsoft answers around 15 ms extra sits near -0.45. Google's 3 to 8 ms delays lean negative. Right: each of ten servers' offset relative to the stratum-1 median with bars of plus or minus half the round trip; stratum-1 servers range from NIST at -5.7 ms to PTB at +3.4 ms; a shaded band from about -6.7 to +3.3 ms marks where all bars overlap.

Left: every exchange at least 1 ms slower than that server's fastest, offset change divided by delay change (clipped at ±0.9; below about 2 ms of extra delay it's mostly noise). Right: dots are the mean of each server's per-block fastest-exchange offset, blue for stratum 1; bars are ± half that server's shortest round trip; the band is where all ten bars overlapped (median over blocks).

The five stratum-1 servers disagreed by 9.1 ms: NIST at −5.7, Google −3.5, Meta 0.0, Apple +0.7 and PTB +3.4 ms. Those positions were stable; each moved by 0.2 to 0.9 ms (standard deviation) from block to block. Since all five keep near-perfect time, the spread is almost all path asymmetry, and it's what my connection would hand to anyone who trusted one of them. There's no way to tell from here which one is right. Every server's true offset has to lie within half its round trip of its estimate, so, as long as each server's own clock is right, the overlap of all ten ranges is a guaranteed bound: 8.8 to 10.4 ms wide in every block, never empty. It's set almost entirely by the closest server (Cloudflare, 9.4 ms round trip). That's the honest answer to "how accurate is my clock": within about ±5 ms, guaranteed, and the best single guess depends on whose path you believe.

The extra delays also had a direction, and the left panel shows it the way the simulation predicts. 23 of Apple's 270 answers came back 38 to 282 ms late, and in 21 of them the offset jumped by almost exactly half the extra time, in the "outbound" direction. Whatever held those requests up (a busy front end, probably) did it before the server read its clock, so to the formula it looks exactly like a slow path out. One answer like that is a 100 ms clock error. They were isolated, though, so a best-of-8 filter throws them away. Microsoft's service had a different pattern: a third of its answers (97 of 299) took about 15 ms longer, and the offset moved by 0.44 of that the other way. That fits a slower return path some of the time, or a second server behind the same name whose clock is a few milliseconds off. The four timestamps can't tell those two apart, which is the whole problem in one example. Google's smaller 3 to 8 ms delays leaned the same way (−0.34).

What I'd do