Simulating NTP with a Tiny Internet
In my previous post, I explained the basic mechanics of NTP: the four timestamps, the delay calculation, and the offset estimate that falls out of them. That discussion was about the protocol in the abstract. This post is about making those ideas concrete.
The simplest way I found to do that was to build a small simulation: one server with the correct time, one client whose clock starts out wrong, and a toy network that adds random delay in both directions. Once you run that simulation a few times, an important property of NTP becomes much easier to see.
A large initial clock error is easy to correct. The harder part is the last few milliseconds.
NTP works by assuming that the network delay from client to server is roughly the same as the delay from server back to client. On a real network that is only approximately true. Queueing, routing, and transient congestion make the two directions differ slightly, and that difference feeds directly into the estimate. The protocol still works extremely well, but its answers are never detached from the path the packets actually took.
That is the part I wanted this simulation to expose clearly.
A 20-Second Refresher
When a client talks to an NTP server, four timestamps matter:
- T1: the client sends the request
- T2: the server receives it
- T3: the server sends the reply
- T4: the client receives the reply
From those four timestamps, the client computes:
delay = (T4 - T1) - (T3 - T2)
offset = ((T2 - T1) + (T3 - T4)) / 2
The first number tells us how much time was spent on the wire. The second tells us how far off the client's clock seems to be.
If the path to the server and the path back were perfectly equal, the offset would be exact. The reason to simulate NTP at all is that they are not.
The Tiny Internet
The simulation has three moving parts:
- A time server that knows the correct time
- A client device whose clock starts out wrong
- A tiny internet that adds separate random delays to the outbound and inbound trip
That is enough to reproduce the basic behaviour of NTP: one large correction followed by smaller, noisier refinements.
The Code
Save this as tiny_ntp.py and run it with python3 tiny_ntp.py.
import random
import time
class TimeServer:
def __init__(self, name, real_start):
self.name = name
self.real_start = real_start
self.epoch = 1_700_000_000.0
def now(self):
return self.epoch + (time.time() - self.real_start)
def handle_request(self):
t2 = self.now()
time.sleep(random.uniform(0.001, 0.005))
t3 = self.now()
return t2, t3
class ClientDevice:
def __init__(self, name, real_start, clock_offset):
self.name = name
self.real_start = real_start
self.epoch = 1_700_000_000.0
self.clock_offset = clock_offset
self.correction = 0.0
def now(self):
return (
self.epoch
+ (time.time() - self.real_start)
+ self.clock_offset
+ self.correction
)
def sync(self, internet, hostname):
t1 = self.now()
t2, t3, outbound, inbound = internet.connect(hostname)
t4 = self.now()
delay = (t4 - t1) - (t3 - t2)
offset = ((t2 - t1) + (t3 - t4)) / 2
asymmetry = inbound - outbound
# Real NTP usually disciplines the clock gradually.
# This toy steps the clock immediately so the effect is easy to see.
self.correction += offset
return {
"t1": t1,
"t2": t2,
"t3": t3,
"t4": t4,
"delay": delay,
"offset": offset,
"outbound": outbound,
"inbound": inbound,
"asymmetry": asymmetry,
}
class TinyInternet:
def __init__(self):
self.hosts = {}
def register(self, hostname, server):
self.hosts[hostname] = server
def _latency(self):
return random.uniform(0.05, 0.30)
def connect(self, hostname):
if hostname not in self.hosts:
raise ConnectionError(f"DNS lookup failed: {hostname}")
server = self.hosts[hostname]
outbound = self._latency()
time.sleep(outbound)
t2, t3 = server.handle_request()
inbound = self._latency()
time.sleep(inbound)
return t2, t3, outbound, inbound
real_start = time.time()
server = TimeServer("time.tiny.com", real_start)
client = ClientDevice("My MacBook", real_start, clock_offset=3.456)
internet = TinyInternet()
internet.register("time.tiny.com", server)
print("=== Before syncing ===")
print(f"{server.name:>14}: {server.now():.6f}")
print(f"{client.name:>14}: {client.now():.6f}")
print(f"{'difference':>14}: {abs(server.now() - client.now()):.6f}s")
print()
for i in range(1, 6):
result = client.sync(internet, "time.tiny.com")
diff = abs(server.now() - client.now())
print(f"--- Sync #{i} ---")
print(
f"T1={result['t1']:.6f} T2={result['t2']:.6f} "
f"T3={result['t3']:.6f} T4={result['t4']:.6f}"
)
print(
f"path: {result['outbound'] * 1000:6.1f} ms out, "
f"{result['inbound'] * 1000:6.1f} ms back"
)
print(f"asymmetry: {result['asymmetry'] * 1000:+6.1f} ms")
print(f"delay: {result['delay']:.6f} s")
print(f"offset: {result['offset']:+.6f} s")
print(f"difference:{diff:>12.6f} s")
print()
Two simplifications are worth stating explicitly.
First, both machines share the same real_start, so they live in the same simulated world and disagree only by the offset we inject. Second, the client applies the full correction immediately. Real NTP is more conservative than that, but for a small teaching program, stepping the clock makes the effect easy to observe.
The main change from the earlier version of this experiment is that the program exposes the network asymmetry directly. TinyInternet.connect() returns the outbound and inbound delays instead of hiding them. Without that, the most important source of error stays invisible.
A Sample Run
Here is a representative run, trimmed down to the first few exchanges:
=== Before syncing ===
time.tiny.com: 1700000000.000021
My MacBook: 1700000003.456025
difference: 3.456004s
--- Sync #1 ---
T1=1700000003.456030 T2=1700000000.102146 T3=1700000000.105799 T4=1700000003.670822
path: 102.1 ms out, 108.9 ms back
asymmetry: +6.8 ms
delay: 0.211139 s
offset: -3.453454 s
difference: 0.002550 s
--- Sync #2 ---
T1=1700000000.217409 T2=1700000000.405950 T3=1700000000.410667 T4=1700000000.621847
path: 188.4 ms out, 206.4 ms back
asymmetry: +18.0 ms
delay: 0.399721 s
offset: +0.009680 s
difference: 0.012230 s
--- Sync #3 ---
T1=1700000000.631612 T2=1700000000.874329 T3=1700000000.878251 T4=1700000001.027197
path: 242.5 ms out, 148.9 ms back
asymmetry: -93.6 ms
delay: 0.391663 s
offset: -0.045858 s
difference: 0.033628 s
Your numbers will differ from run to run. The pattern will be similar.
How To Read That Output
Start with the obvious part: the client begins 3.456 seconds wrong. After the first exchange, that error collapses to a few milliseconds. That is the main strength of NTP in practice.
Then notice what happens next. The client does not move monotonically toward zero error. It oscillates. One round leaves it 2 milliseconds off. The next leaves it 12 milliseconds off. Then 33 milliseconds. That is not a bug in the simulation. It is a direct consequence of the assumptions in the estimator.
The asymmetry line explains why. In the third sync above, the outbound trip is about 242.5 ms and the return trip is about 148.9 ms. Those paths differ by roughly 93.6 ms. Since the offset formula assumes they are equal, some of that asymmetry leaks into the clock estimate.
That is the whole point of this tiny internet. It turns NTP from a formula on paper into a system whose errors can be inspected directly. Once you can see those errors, it becomes much easier to understand why the real protocol relies on repeated samples, filtering, and gradual correction.
What This Toy Leaves Out
This is not an NTP implementation. It is a teaching prop.
Real NTP does substantially more work than this simulation.
-
It samples repeatedly, not once. A single exchange is only one noisy observation. Real implementations poll over time so that the clock can be corrected using a stream of measurements rather than a single guess.
-
It disciplines the clock instead of simply stepping it. This toy applies the full offset immediately. A real system usually slews the clock by making it run slightly faster or slower, because abrupt jumps can break timers, logs, and long-running processes.
-
It estimates frequency error, not just time offset. A clock is not merely wrong at an instant; it also runs fast or slow. Production NTP implementations continuously estimate oscillator drift so the clock stays accurate between polls.
-
It filters bad samples. Some exchanges are obviously worse than others because of queueing spikes, transient congestion, or other delay anomalies. Real NTP prefers lower-delay, lower-jitter samples and gives less weight to noisy ones.
-
It compares multiple time sources. A real client does not usually treat a single server as unquestioned truth. It evaluates several upstream sources, looks for agreement among them, and avoids sources that appear inconsistent or unstable.
-
It keeps track of quality, not just the current estimate. Terms like jitter, dispersion, and root distance matter because the system needs to know not only what time it believes it is, but also how trustworthy that belief currently is.
-
It sits inside a larger hierarchy. NTP servers have strata, upstream references, and varying distance from primary reference clocks. In practice, timekeeping is a layered system, not a single client asking a single perfect server for the time.
I left all of that out on purpose. Once the full machinery goes into the code, the central idea becomes harder to see, and for this post the central idea is the effect of delay asymmetry on the offset estimate.
Try It Yourself
Change clock_offset to 30.0 or 500.0 and the first sync still does most of the work. Widen the latency range to something harsher like random.uniform(0.1, 2.0) and the oscillations become more obvious. If you want to push the experiment further, print half the asymmetry next to the computed offset and compare the two.
That is one of the reasons NTP remains such a satisfying protocol to study. It does not require a perfect network, or even a particularly stable one. It relies on a small amount of information, a reasonable assumption about delay, and repeated measurement.