Skip to content

Shared clock

Some state needs no syncing at all once everyone agrees on the time. An enemy walking a fixed route at a fixed speed is in the same place on every screen if every client knows when it set off; a countdown ends together if every client knows when it ends. Sending the position ten times a second over presence works, but it is bandwidth spent on a fact each client could compute. What those clients lack is a clock they agree on.

The connection clock is that clock. It is off until the app asks for it:

client.clock.start();
// Later, on any client of the same server:
await client.clock.ready(); // synced: now() reads server time
const waveStartsAt = client.clock.now() + 3_000; // write this into a document
// …and each frame, on every client:
const elapsed = client.clock.now() - game.waveStartsAt;

client.clock.now() is server time, in milliseconds since the Unix epoch. Write it into documents, compare it across clients, compute from it on your own frames. Until the first answer it is the local clock, so wait on client.clock.ready() before writing a time other clients will compute from; it resolves once the clock has synced, at once if it already has.

A started clock sends clock:ping over the connection and the server answers clock:pong with its own time (wire protocol). From one round trip the client knows the server read its clock somewhere between sending and receiving, and takes the midpoint, so a single sample is off by at most half its round trip.

It takes five samples back to back on every connect, then one a minute, and keeps the offset from the fastest round trip among its recent samples (NTP’s minimum-delay filter): the shortest trip leaves the least room for the two legs to differ. client.clock.status reports synced, the offsetMs and the rttMs of the sample it came from; client.clock.subscribe hears every change.

now() is the local monotonic clock plus that offset, and a better sample does not make it jump: it slews to the new offset, running up to 10% fast or slow until it has caught up, the way NTP and game clocks do. So now() never runs backwards, and a 20 ms correction is spread over 200 ms. Only the first sync, and a correction over 250 ms, step at once; status.offsetMs is always the estimate itself, which now() may still be catching up with.

Offline now() keeps counting on the last offset. A reconnect samples afresh, since a new connection may take a different path, but the old estimate stays in the running. Each sample is off by at most half its round trip, so a slow first answer whose range overlaps the old estimate’s cannot replace it. One whose range does not proves the old estimate wrong (the local clock paused while the laptop slept, say), and wins at once.

A client that never calls start() sends no clock traffic, which matters on a Durable Object: the heartbeat is answered at the edge without waking the object, but a clock ping is a real message the object handles. start({ burstSamples, resyncIntervalMs, sampleTimeoutMs }) tunes the cadence; resyncIntervalMs: null samples on connect only.

The clock belongs to the connection, not to presence or to a document: one clock serves every channel and every document the client touches.

In the in-memory demos every client shares the page’s clock, so the offset is near zero by construction; a LatencyLink delays both directions alike, which the midpoint cancels. To see the clock correct something, give a client a skewed local clock:

connectInMemoryClient(connection, schemas, {
localClock: () => performance.timeOrigin + performance.now() + 2_000,
});
  • Agreeing on time is not agreeing on outcomes. A computed enemy still dies where the authority says it does, and that news arrives one-way latency late. Draw slightly in the past, as games do: Contingency draws half the clock’s rttMs plus a tick behind now(), so a kill has arrived before the enemy walks past it.
  • Precision is the network’s. The estimate is as good as the most symmetric recent round trip; a path whose legs are consistently lopsided biases it by half the difference, and nothing on the client can see that.