Real-time games
Browser games run into the same networking problems again and again: input replayed after a reconnect, choppy movement, controls that lag by a round trip, hand-rolled request ids, ping displays, timers that disagree between players, and React rendering at 60 fps. This page covers the tools for each.
react-websocket stays protocol-agnostic. You describe your messages once and it handles the bookkeeping. Snapshot interpolation and client-side prediction come from react-websocket/timeline. Rooms and matchmaking depend on your server, so they're left to you or to a game server framework. For all of it working together, run the game example.
The setup
The examples on this page share one client:
Input: don't replay stale messages
By default, send() while disconnected queues the message, and the queue is flushed when the socket opens. That's right for chat, but wrong for input. After a three-second drop, every buffered shot goes off at once.
Pass volatile for messages that are worthless once they're late:
A volatile message goes out immediately when the socket is open and is dropped otherwise. send() returns false when it was dropped.
For state where only the latest value matters, pass a key instead. While disconnected, sending again with the same key replaces the queued message, so only the newest one is sent on reconnect:
The replacement moves to the back of the queue, after anything you sent before it.
On a slow link, a message sent every tick can also pile up while the socket is open: the browser buffers what it can't send yet, and every message waits behind the ones before it. Add maxBuffered to drop a message while more than that many bytes are already waiting, so the buffer drains instead of growing:
Use it for messages where the next one replaces this one anyway. Don't use it for one-shot actions such as fire or jump, which must arrive.
Movement you want to show right away goes through the timeline instead, which sends it volatile for you and takes maxBuffered too.
With react-websocket/webtransport, volatile messages go as datagrams: they skip the queue on the network too, so a lost one never holds up the next. They can also be lost while connected, so keep fire and jump on ordinary sends there, or pass datagrams: false.
Requests: await the server's answer
"Join a room and wait for the answer" needs a request id, a map of pending promises, a timeout, and cleanup on disconnect. createWebSocketRequests does that bookkeeping. You tell it where the id goes and how to recognize a reply:
Annotate send's message parameter: that's what request() accepts. Then:
A request rejects instead of hanging forever:
A request made while disconnected waits in the queue and goes out on the next connection. Its timeout still runs. Pass { volatile: true } to reject right away instead. A pending request also holds the connection, so await rpc.request(...) works outside React too.
Cancel requests a component no longer needs:
Replies are still ordinary messages: they reach onMessage, lastMessage and state stores too.
Ping and server time
createWebSocketLatency pings on an interval while the socket is open and measures the round trip. If your server puts its clock in the pong, it also estimates the offset between the server's clock and the player's:
The server echoes the id and adds its own Date.now():
Show the ping with useWebSocketLatency:
Use latency.serverNow() anywhere every player must see the same time, such as a countdown to a round that ends at the server's endsAt:
Player clocks are often seconds off. Date.now() would give each player a different countdown, but serverNow() corrects for the offset.
rttis the median of the lastsamples(default 5) round trips, so one slow ping doesn't make the number jump.offsetcomes from the fastest of those samples, which is the most accurate.- Pings are
volatileand pause while disconnected. After a reconnect, old samples are discarded, but the last values stay readable until new ones arrive, so a countdown doesn't jump.
Smooth movement: snapshot interpolation
Servers usually send the world 10 to 30 times a second. Drawing each snapshot as it arrives looks choppy, and network jitter makes the gaps uneven. The fix is to render slightly in the past, between two snapshots you already have, and blend them.
createWebSocketTimeline, from react-websocket/timeline, keeps snapshots by server time and does the blending:
timeline.at() returns the world at timeline.renderTime(), about delay ms (default 100) behind the newest snapshot, blended between the two snapshots around that time. Call it every frame.
- Picking
delay. It should cover the time between snapshots plus jitter, about two intervals. At 20 snapshots a second (50 ms apart), the default 100 ms survives one late snapshot. Too little andat()runs out of snapshots and holds still. Too much and everything lags. - Late snapshots. When the render time passes the newest snapshot,
at()holds it. Withextrapolate: 50, it continues the motion for up to 50 ms instead, which hides one late snapshot but overshoots when a player stops. - The clock. With
latency, the timeline uses the server clock from pings. Without it, it estimates the clock from when snapshots arrive, which works too.
To render only as far behind as the connection needs, pass a function. This one measures how unevenly snapshots arrive:
The render time never goes backward, so when the delay grows, the world slows down for a moment instead of jumping back.
Responsive controls: prediction and reconciliation
Rendering in the past is right for other players, but not for your own: a key press would take a round trip plus the delay to show up. Instead, apply your input right away, and let the server correct you.
Give the timeline an input option:
Then send movement with timeline.input() instead of send():
What happens:
- Each input gets a sequence number,
seq, and is sentvolatile. If it can't be sent (the socket isn't open, or the buffer is abovemaxBuffered),input()returnsundefinedand nothing is predicted. timeline.predicted()returns the newest snapshot with every input the server hasn't confirmed replayed on top, withpredict.- Each snapshot carries
ack, the lastseqthe server applied. Confirmed inputs are dropped, and the rest are replayed on the new snapshot. If the server disagreed, say you walked into a wall only it knew about, your player snaps to where the server says, plus the inputs still on their way.
predict must do exactly what the server does. Share the simulation: put move() in a module both import. Annotate send's input parameter, since that's what timeline.input() accepts.
Drawing both
Draw other players from at() and yourself from predicted():
Your player is drawn in the present, and everyone else about delay plus half a round trip in the past. That's the usual trade-off. Where it matters, such as hit detection, tell the server what you saw: send seen: timeline.renderTime() with the shot, and let the server rewind the other players to that time. That's called lag compensation.
Smoothing corrections
A misprediction makes your player jump. To hide small ones, ease the drawn position toward the predicted one each frame instead of drawing it as is:
Poor connections: adaptive send rate
A player on a slow link can't upload 60 messages a second. The browser buffers what it can't send yet, every message waits behind the ones before it, and the lag keeps growing. maxBuffered stops the growth by dropping messages, but it's better not to send them in the first place.
createWebSocketSendRate sends state such as aim or a cursor at a rate that adapts. It ticks up to maxRate times a second, halves the rate when the send buffer fills up or the round trip rises, and climbs back one tick a second at a time while the link keeps up. Between ticks, only the newest message per key is kept:
Before a one-shot action that must carry the newest state, flush what's waiting:
A steady high ping keeps the full rate. Only a round trip that rises counts, because that's what a filling queue looks like.
Don't send predicted inputs through it: coalescing drops messages, and a dropped input is never confirmed. Use the rate to send fewer, bigger inputs instead. Each input says how many simulation steps it covers, and both predict and the server apply them all:
The timeline only predicts inputs it has sent, so draw your player with the steps not sent yet on top: move(mine, { dx, dy, steps }). The rate adapts on ticks, so this works while you stream something, such as aim, through send(). stream.getSnapshot() returns { rate, congested } for a HUD. The game example has a switch to compare.
The server's side
The timeline needs four things from your server:
- A
timeon every snapshot, in ms, from a clock that never goes backward. Use the same clock for pongs, solatencyand the timeline agree. In Node,performance.timeOrigin + performance.now()works for both. - An
ackfor each client: the last inputseqthe server applied from that client. If one message goes to everyone, include every player's ack and pick yours out insnapshot. - Inputs applied in
seqorder, with the same code aspredict. Ignore aseqat or below the last one applied. Sequence numbers keep counting across reconnects, so don't expect them to start at 1. - A fixed tick. Apply queued inputs and send snapshots at a steady rate, such as 20 times a second.
The game example has a complete server.
If your server sends deltas instead of the full world, rebuild it in snapshot from the previous one: state: apply(timeline.latest()?.state, message.delta).
Rendering at 60 fps
Don't render game state through React on every message. Read the timeline in your render loop instead: at() and predicted() never trigger a React render.
With React Three Fiber, do the same in useFrame(() => { … timeline.at() … }). For a slow or turn-based game that doesn't need interpolation, reduce messages into a createWebSocketState store and read getState() in the loop the same way.
Keep useWebSocketState for the HUD (score, health, the player list), where a selector re-renders only when its value changes. Pass batch: 'frame', so that even a value that changes on every tick renders at most once per frame:
Don't read at(), predicted() or renderTime() through useSyncExternalStore: they change with time, not when the timeline notifies. timeline.pending() and timeline.latest() are fine there.
Rejoining after a reconnect
The server forgets who a connection was when it drops. Identify again from an open listener: it runs before anything queued while disconnected is sent.
The timeline starts over by itself: it drops inputs the old connection never confirmed, and keeps showing the last frame until the first snapshot on the new connection. Don't call timeline.reset() from open, or the screen goes blank until then. Reset any state stores that the new connection fills from scratch.
If the session token rotates, put it in the URL with a URL function instead. If you hold topics, they're subscribed again automatically.
Binary protocols
For MessagePack, Protocol Buffers or a hand-packed format, receive ArrayBuffers and plug in your codec:
Everything on this page works the same with a binary codec.
Background tabs
Browsers throttle timers in background tabs to about once per second, and sometimes to once per minute, and stop animation frames. That has three consequences:
- A short
heartbeat.timeoutcan fire because the tab is asleep, not because the server is dead. Keep the timeout well above a few seconds. - Ping and countdown displays are only meaningful while visible. The client already retries right away when the tab becomes visible again.
- Animation frames stop, so nothing reads the timeline. When the tab is visible again, the render time catches up with the present at once.
See also
createWebSocketTimeline,createWebSocketSendRate,createWebSocketRequests,createWebSocketLatency,useWebSocketLatency- The game example: a multiplayer demo with a network panel. Build a real-time action game walks through it file by file
- Build a multiplayer party game: rooms, a countdown every screen agrees on, and answers counted once
SendOptions- Message state and Performance