Cloudflare Durable Objects · netcode
A browser 1v1 — and now 2v2 — running a 60Hz server-authoritative simulation inside one Cloudflare Durable Object. Four things are worth showing, and all four are things you have to watch rather than read.
The shape of it
The whole game lives in one Durable Object: sixteen arenas, one timer, and a simulation that owns no clock of its own. Here is what actually happens when you press a key — watch one input make the trip.
Everything below pump.ts is fed time rather than reading it.
Match takes wall clock through advance(ms); the simulation itself
imports nothing from the DOM, the network, or a clock, and has no randomness. That purity is
why the same code runs on the server, in tests, and in the client's prediction path — and
why moving it onto workerd took no polyfill and no shim.
Result 1 · the runtime
A Durable Object needs something to drive it. Cloudflare's docs point at
alarm(). We measured both, on real production infrastructure — and the
difference is not subtle.
Locally, wrangler dev reports alarms at 59.01Hz and intervals
at 58.46Hz — alarms marginally ahead. The ranking inverts in
production. Stopping at local numbers would have shipped the bottom lane.
Your development environment is a model of production, and models are wrong in the direction nobody checks.
Result 2 · the client
A server-authoritative game has a problem: the server decides, and the server is far away. Wait for its reply and every keypress costs a round trip. The fix is to move immediately and correct later.
Below, both champions obey you. The left one waits for the server. The right one predicts. Hold the button and watch which one you'd rather play.
D / arrow key · release to stop
Result 3 · fairness
Prediction fixes your champion. It does nothing for your opponent, who reaches you over the same network — so by the time you see them, they have already moved. Shoot at what is on your screen and, without help, you miss.
So the server keeps 18 ticks of pose history and rewinds. Your shot is judged against the world as you actually saw it.
Interpolating between two stored poses is a good approximation of a real path — walking covers about 3.4 units a tick. Then we shipped a 400-unit blink, and the interpolated midpoint became a place nobody has ever been. Rewound shots hit a phantom standing in the gap.
Continuous state interpolates. Discrete state — alive, shield raised, and now warped — never does.
Result 4 · the link
Cloudflare's edge is anycast: one address announced from about 330 sites, and BGP — not the browser and not us — settles which of them answers. It settles once per TCP connection and then holds for that connection's whole life. Measured from Los Angeles on 2026-08-31 over 46 real connections, the same player was terminated in Portland (~65ms), Miami (~152ms), Osaka (~222ms) and Sydney (~309ms) on consecutive attempts. A quarter of the draws were four times worse than the best on offer — on every frame, for the whole session.
So connecting is not one socket. The client opens one, pings it three times, takes the floor of the three, and either keeps it or holds it open and draws again — up to ten sockets or three seconds, and then the best one it drew, never the last one. Holding the losers is the whole design: with every earlier draw still open there is no stopping rule to get right.
The bar it draws against is yours rather than a constant — 1.3× the best round trip this browser has ever measured, seeded on a first visit by the floor the server tells the connection its geography allows. A fixed 100ms would make London burn all ten sockets on every connect to land exactly where its first draw already was; a fixed 200ms would cost Los Angeles 54ms by stopping at Miami and never finding Portland. There is no constant that is right for both. The memory decays by a tenth on a roll that met nothing, so a player whose old best has become unreachable does not roll ten sockets forever.
None of this is hidden while it happens: the status line reads
finding a route · socket 2/10 · 152ms · want ≤130ms until it settles.
A socket that closes does not empty its seat. The server puts a stand-in in it — the champion simply stands still — and holds the seat, the name and the queue row for 15 seconds. The client comes back to the same address with a one-shot token, on a backoff bounded by that window, and resyncs the frame; it deliberately does not roll again, because swapping the socket under a running match is what loses the seat.
What it is not is a promise. A duel that reaches a winner rotates around a stand-in exactly as it rotates around a live loser, so a reconnect can be handed back its seat, a place in the queue, or neither. Fifteen seconds is a judgement about how long a match is worth pausing for, not a measurement.
The numbers
| measured 2026-08-29, production Cloudflare | result | standing |
|---|---|---|
| setInterval tick | 62.5 Hz · gap 16/16/16 ms | Measured |
| alarm() tick | 10.76 Hz · gap p50 92 ms | Measured |
| Cost of one tick | ≤6 µs · ~0.05% of a core | Measured externally — a bound |
| performance.now() inside a tick | frozen | Measured — local dev disagrees |
| Perceived input latency | 177 → 17 ms | Measured in a browser |
| Snapshot size / rate | 521 B · 60Hz seat, 20Hz watching | Measured |
| Rewind window | 300 ms | A judgement, not a measurement |
| Edge draw, one player, 46 connects | 65 – 309 ms | Measured 2026-08-31 from Los Angeles |
| Sockets drawn per connect, adaptive | LA 4.7 · London 1.2 · Sydney 2.2 | Modelled over that distribution |
| Seat held after a socket drops | 15 s | A judgement, not a measurement |
| Monthly cost, one arena ticking | 324k GB-s of 400k included | Modelled over published pricing |
Three things we have not settled, because a page that quotes the measurements without the gaps is selling rather than reporting: the longest production run was ten seconds, so sustained 62.5Hz is unverified. Hibernation was never exercised, and it is what decides the bill for an idle server. And the spike drove its loop over HTTP, so socket fan-out at rate is unmeasured.
One thing we did measure at scale surprised us. At around 300 connections, production began dropping sockets — and the failure was not that spectators saw less. It was the tick falling to 18Hz, so the two people actually playing took the match in slow motion. That is why the audience can never be allowed to raise the cap.