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.

StageHow longWhere you change it
The flush window on the writing clientUp to one animation frame, and never more than writeIntervalMs (50 ms by default)joinRoom({ writeIntervalMs }), or room.flush() to send now
Network, client to serverYour player’s connectionNowhere
Waiting for the next tickUp to 1 / tickRate (50 ms at the default tickRate of 20)tickRate in irtio/room.ts
Network, server to every other clientTheir connectionsNowhere
The render buffer on the reading clientinterpDelayMs, which defaults to twice the room’s tick interval and never less than 50 msjoinRoom({ 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:

ValueOwnerWhat the player feels
Cursor, avatar position, aim, cameraThe clientInstant
Movement intent in a physics roomThe client, as intent fieldsInstant with prediction, one round trip without
Score, damage, turn order, deckThe serverOne 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:

InvariantFails whenDefault
correction-stormCorrections per second per bot go over the threshold5/s, raise with --corrections-max
snapsCorrections outran the client’s resimulation windowunlimited, 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