Latency and what players feel
A player never feels the network. They feel the gap between pressing a key and seeing something happen, and the smoothness of everyone else’s motion. Those are two different problems with two different fixes, and most of what decides them is in your room file rather than on the wire.
Start by reading the number you have:
// main.ts
const room = await joinRoom(schema, { name: 'you' });
room.rtt; // ms, smoothed, from the last ping
room.on('rtt', (ms) => showPing(ms)); // fires after each pong, about every 2 s room.rtt is 0 until the first pong.
What the round trip is made of
Between a key press and the moment every other player sees the result, a write waits in five places.
| Stage | How long | Where you change it |
|---|---|---|
| The flush window on the writing client | Up to one animation frame, and never more than writeIntervalMs (50 ms by default) | joinRoom({ writeIntervalMs }), or room.flush() to send now |
| Network, client to server | Your player’s connection | Nowhere |
| Waiting for the next tick | Up to 1 / tickRate (50 ms at the default tickRate of 20) | tickRate in irtio/room.ts |
| Network, server to every other client | Their connections | Nowhere |
| The render buffer on the reading client | interpDelayMs, which defaults to twice the room’s tick interval and never less than 50 ms | joinRoom({ interpDelayMs }) |
The two you control are the first and the last, and both of them are buying something. The flush window turns a burst of field writes into one frame. The render buffer gives interpolation two samples to move between.
The player’s own value skips the whole list. A client applies its own owned write locally the moment you make it, so a cursor, an avatar or a dragged card answers input at frame rate no matter what the connection is doing. What travels is everyone else’s view of it.
What interpolation hides
room.render draws non-owned entities at now - interpDelayMs, blending numeric fields between
the two authoritative values either side of that point. Everything else steps.
// main.ts
function frame() {
// The only change from room.state: values advance with the frame clock, not with arrivals.
for (const [id, p] of room.render.players) draw(id, p);
requestAnimationFrame(frame);
} Interpolation hides jitter: packets arriving unevenly, ticks landing late, a frame rate that does not divide the tick rate. It turns 20 updates a second into motion at your display’s rate.
It does not hide latency. It adds some. A body drawn at now - interpDelayMs is by definition
behind. That is the trade: room.state is the freshest value and moves in steps, room.render is
one buffer late and moves smoothly.
Set interpolate: false on a collection whose values should snap rather than blend, such as a
phase enum or a tile index.
What prediction hides
Client prediction builds a local physics world from the same code the room uses, runs it ahead, and serves your own body from it. Input feels immediate, and every authoritative tick the local world is rebased onto the server’s values and re-stepped.
Prediction hides your own latency, on your own body, in a physics room. Measured in one example game, that puts key press to visible movement at a median of 34 to 62 ms across 0 to 150 ms of round trip.
It needs a shared world builder that both halves import, and it costs a physics world on every client. It is worth it when the player directly steers a body and the game is about how that body moves.
What neither hides
- Another player’s input. You cannot know what they pressed before the server does. Contact with another player’s body mispredicts either way, and no setting removes it.
- An outcome the server decides. An RPC reply is one round trip, always. A hit result, a card draw, or a turn change arrives when it arrives.
- A connection worse than the resimulation window. A predicted body whose lead runs past 20
ticks snaps to authority instead of re-stepping. Watch
room.prediction.stats.snaps, which should sit at zero. - A room that is asleep. Measured against the hosted service, joining an awake room takes about 120 ms and joining a room that has to wake takes about 525 ms at the median. Both are join costs, not per-frame costs.
There is no lag compensation and no server-side rewind in irtio today. A hit is judged against the state the server holds when the RPC arrives, so design shots and hits around that rather than around a rewound world.
The choices that matter more than the network
Pick a tick rate you need. tickRate defaults to 20 and takes an integer from 1 to 240. It
sets how long a write waits for its tick, how often deltas leave, and how much bandwidth the room
spends. 20 is right for most games. 30 or 60 is for a physics room where contact resolution is the
point. A quiz does not need a tick loop at all: mode: 'event' runs handlers as they arrive and
lets the room hibernate.
Give the client the values it should own. This is the largest lever on this page. A value the client owns is written locally and drawn at frame rate. A value the server owns costs a round trip to change. Own the report, let the server own the outcome:
| Value | Owner | What the player feels |
|---|---|---|
| Cursor, avatar position, aim, camera | The client | Instant |
| Movement intent in a physics room | The client, as intent fields | Instant with prediction, one round trip without |
| Score, damage, turn order, deck | The server | One round trip, and correctly |
Do not shrink the flush window by reflex. Writes are batched so that ten field writes in one
frame are one frame on the wire. Lowering writeIntervalMs sends more, smaller frames and buys
almost nothing, because in a browser the batcher already aligns to the animation frame. Use room.flush() for the one write that must go now, such as the frame a player releases a shot.
Do not rate-limit the drawn position yourself. Your own limiter cannot tell movement from a
correction, so it lags real motion too. Prediction already eases corrections out with smoothingHalfLifeMs, 70 by default.
Keep the frames small. A late frame is a large frame that queued. Measure with the bandwidth profiler before you touch anything timing-shaped, and design the schema against a budget as described in Designing a schema that fits.
Measuring instead of guessing
irtio simulate opens real client sessions over real sockets, with the same write batching,
prediction and reconnect logic a browser gets, and fails the run on a violated invariant.
npx irtio dev # in one terminal
npx irtio simulate --bots 20 # in another Two of its invariants are latency-shaped:
| Invariant | Fails when | Default |
|---|---|---|
correction-storm | Corrections per second per bot go over the threshold | 5/s, raise with --corrections-max |
snaps | Corrections outran the client’s resimulation window | unlimited, set with --snaps-max |
A correction storm means clients and server disagree constantly, which a player reads as rubberbanding. Snaps mean the connection is worse than the window the client can replay.
The simulator can also run with degraded links, which is where a timing assumption breaks before a player finds it. See Invariants and load runs.
Next steps
- Client prediction for the local world, corrections and the stats.
- Choosing an authority model for what each model costs a player.
- Client SDK for
joinRoomoptions,room.renderandroom.rtt. - Invariants and load runs for the full invariant list.