"What tick rate should my server run at?" is the wrong first question, and it's the one everybody asks. The useful version is: which rate — because an authoritative server has at least three of them, and most netcode pain comes from conflating them. This is a practical guide to picking a simulation rate, a send rate and an interpolation delay, writing a loop in Node that doesn't drift, and doing the bandwidth arithmetic before you find out the hard way.
Three rates, not one
An authoritative server owns the truth: clients send inputs, the server simulates, the server tells everyone what happened. Three independent clocks fall out of that:
- Simulation rate (tick rate). How often the world advances one fixed step. Determines physics fidelity and how quickly an input can possibly take effect.
- Send rate (snapshot rate). How often the server broadcasts state. Determines bandwidth and server egress cost.
- Client render rate. Whatever
requestAnimationFramegives you, usually 60–144 Hz, unrelated to the two above.
Simulating at 60 Hz and sending at 20 Hz is a completely normal, boring configuration: broadcast every third tick. The client hides the gaps with interpolation. Coupling the two — sending a packet on every tick — is how a 60 Hz server ends up with a bandwidth bill three times larger than it needed for the same feel.
Choosing the simulation rate
The tick rate sets a hard per-tick compute budget for the whole match: simulation, collision, validation and serialisation must all finish inside it, or the server falls behind.
- 10–30 Hz — 33–100 ms per tick. Co-op, sandbox, survival, strategy, card and board games, large worlds with 100+ entities. Cheap, and nobody notices.
- 60–64 Hz — about 16 ms per tick. The default for action games and team shooters with a handful of players per match.
- 128 Hz — 7.8 ms per tick. Competitive tactical shooters. Riot runs Valorant here and had to optimise the server frame down to a few milliseconds to keep matches per core economical.
Doubling the tick rate roughly doubles CPU per match, and CPU per match is what your hosting bill actually is. So the honest process is: pick the lowest band your genre tolerates, instrument the tick duration (p50 and p99, not the average), and only move up when hit registration or responsiveness measurably suffers. If your p99 tick is already at 80% of budget, raising the rate will not make the game feel better — it will make it stutter.
A loop that doesn't drift
The classic mistake is setInterval(tick, 16). setInterval guarantees
only a minimum delay, and Node re-arms the timer after your callback returns, so every
overrun is permanently added to the server's clock. Ten minutes later the world is seconds behind
real time and every client's interpolation is wrong.
Use an accumulator against a monotonic clock, and step a fixed size as many times as fits:
const STEP = 1000 / 60;
let acc = 0, last = performance.now(), tick = 0;
function loop() {
const now = performance.now();
acc += Math.min(now - last, 250); // clamp after a stall
last = now;
while (acc >= STEP) {
stepWorld(STEP, tick++); // fixed dt, never variable
acc -= STEP;
if (tick % 3 === 0) broadcastSnapshot(tick);
}
setTimeout(loop, Math.max(0, STEP - acc));
}
Two details matter more than they look. Pass the fixed dt, never the measured
one — variable dt destroys reproducibility and makes desyncs unfindable (the
reasoning is the same as in
lockstep vs rollback). And clamp the accumulator:
after a GC pause or a suspended process, an unclamped accumulator tries to catch up with hundreds
of steps in one go, which stalls the process further — the "spiral of death".
The bandwidth arithmetic
Do this on paper before you optimise anything. Take a 32-player match, 12 bytes of state per player after quantisation (position and angle as 16-bit fixed-point rather than three float64s), and a 20 Hz send rate:
- 384 bytes per snapshot (32 × 12)
- 7,680 bytes/second per client (× 20) ≈ 7.7 kB/s, about 61 kbit/s before headers
- × 32 clients ≈ 246 kB/s of egress per match
That is comfortable. Now change three things naively — float64 fields (24+ bytes), JSON instead of a binary buffer (3–5× again), and sending on every 60 Hz tick — and the same match is in the low megabytes per second. The three levers, in order of payoff: send rate, quantisation (see fixed-point maths), and relevance filtering — don't send entities a client cannot see. Delta encoding against the client's last acknowledged snapshot is the next step up, and the point where you need per-client state on the server.
Interpolation delay: the number people get wrong
Remote entities arrive as discrete snapshots, so the client renders them slightly in the past and interpolates between the two snapshots that straddle "now minus delay". Choose the delay as one snapshot interval plus your jitter budget. At 20 Hz that's 50 ms plus headroom, so around 100 ms — two intervals — which survives exactly one lost or late packet before the buffer runs dry. Geckos.io's snapshot-interpolation library defaults to a buffer three server frames long for the same reason.
What happens when the buffer does run dry is a design decision, and the two answers have different failure modes:
- Hold the last position until a snapshot arrives. Players stop and then jump. Ugly, but never wrong.
- Extrapolate from last known velocity. Smooth, but overshoots into walls and has to be corrected. Reasonable for slow vehicles, poor for a player who can change direction instantly.
Your own character is exempt from all of this: it's predicted locally and reconciled against the server's last acknowledged input sequence number, so it stays at zero delay. That asymmetry is the whole trick — you live in the present, everyone else is 100 ms in the past.
Lag compensation, and how far to rewind
Add those two facts together and hit detection breaks. When a client fires, the target on their screen was at a position that is (their one-way latency + interpolation delay) old. If the server checks the shot against the present world, players have to lead their targets — which feels like the netcode is broken, because it is.
The fix, unchanged since Valve's 2001 write-up on latency compensation, is a ring buffer of recent world states on the server — one second at 60 Hz is 60 entries, which is nothing. When a shot arrives, rewind the relevant hitboxes to the shooter's view time, test there, then apply the result in the present. Two guardrails: cap the rewind (a few hundred milliseconds; beyond that, reject rather than trust), and compute the view time from server-side data — the acknowledged input sequence and your own latency estimate — not from a timestamp the client sent you, or you have handed cheaters a time machine.
A short pre-launch checklist
- Simulation and send rate are separate constants, and both are logged at startup.
- Tick duration p50/p99 is a metric you can graph. Budget alarm at 70%.
- Fixed
dteverywhere; accumulator clamped. - Snapshots are binary and quantised, not JSON.
- Interpolation delay ≥ one snapshot interval + jitter, and configurable without a redeploy.
- Client can only ever say "my buttons look like this" — magnitude clamped, schema-validated, rate-limited. Never a position, never a speed.
- Rewind window capped, view time derived server-side.
None of this is exotic, and that's rather the point: authoritative multiplayer that feels good is mostly four numbers chosen deliberately and a loop that keeps honest time. Get those right and you can spend your remaining budget on the game.