Server-authoritative multiplayer on a budget

"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 requestAnimationFrame gives 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.

client input @ 60 Hz predict + reconcile render −100 ms server (authority) sim @ 60 Hz fixed snapshot @ 20 Hz state ring for rewind {seq, buttons, aim} snapshot + ack(seq) server ticks (60 Hz, 16.67 ms apart) ▮ every 3rd tick → broadcast snapshot (20 Hz, 50 ms apart) client interpolation buffer ≈ 2 snapshots ≈ 100 ms

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

  1. Simulation and send rate are separate constants, and both are logged at startup.
  2. Tick duration p50/p99 is a metric you can graph. Budget alarm at 70%.
  3. Fixed dt everywhere; accumulator clamped.
  4. Snapshots are binary and quantised, not JSON.
  5. Interpolation delay ≥ one snapshot interval + jitter, and configurable without a redeploy.
  6. Client can only ever say "my buttons look like this" — magnitude clamped, schema-validated, rate-limited. Never a position, never a speed.
  7. 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.

Related
→ Client-side prediction and rollback netcode → Lockstep vs rollback: how to choose → WebSocket vs SSE vs WebTransport → Portfolio & projects
What tick rate should my game server use?

Match the genre. Co-op, sandbox and slower games are fine at 10–30 Hz; action games and team shooters sit at 60–64 Hz (~16 ms per tick); competitive tactical shooters run 128 Hz (7.8 ms). Doubling the rate roughly doubles CPU per match, so start low, measure p50/p99 tick duration, and raise only when responsiveness measurably suffers.

Is tick rate the same as the rate the server sends updates?

No — separating them is the cheapest win in netcode. Simulate at 60 Hz and broadcast every third tick for a 20 Hz send rate. Simulation rate controls fidelity; send rate controls bandwidth. Entity interpolation on the client hides the gaps.

How much interpolation delay do I need?

At least one snapshot interval plus jitter budget. At 20 Hz a snapshot arrives every 50 ms, so about 100 ms of buffer absorbs one late or lost packet. Less than one interval and you stall constantly; much more and remote players visibly trail reality.

Why does setInterval drift in a Node game loop?

It guarantees only a minimum delay and is re-armed after your callback returns, so every millisecond of overrun accumulates permanently. Use a monotonic-clock accumulator, run as many exact fixed steps as fit, keep the remainder, and clamp the accumulator so a long stall can't trigger a catch-up spiral.

Do I still need lag compensation if I have an authoritative server?

For instant-hit weapons, yes. The shooter aimed at where the target appeared, which is their latency plus interpolation delay in the past. Keep a ring buffer of recent world states, rewind hitboxes to the shooter's view time, cap the rewind at a few hundred milliseconds, and derive that view time server-side rather than trusting a client timestamp.