Building a Low-Latency Multiplayer Game Backend with WebRTC
A WebSocket delivers every message reliably and in order over TCP — which sounds like exactly what a multiplayer game needs, until you realize that guarantee is the problem. If packet 41 is lost, TCP blocks packets 42 through 50 behind it until 41 is retransmitted, even though a player's position update from two frames ago is already useless by the time it arrives — the game needs the newest state, not every state in order. WebRTC's RTCDataChannel can be configured unordered and unreliable, which sounds worse on paper but is exactly the delivery model a fast-twitch multiplayer game needs: drop the old packet, never wait for it.
This guide builds one running example: a top-down multiplayer game where each client sends its position 20 times a second over an unordered, unreliable data channel, a signaling server brokers the peer connection through NAT, and the client runs local prediction so a player's own movement feels instant despite 80ms of network latency to other players. You'll see why WebRTC's transport model fits real-time games better than WebSockets, how two players behind different NATs actually find each other, the client-prediction and reconciliation pattern that hides latency, and what happens when a connection can't traverse NAT directly. For the CRDT-based approach to real-time state sync used by collaborative apps rather than games, see real-time collaborative apps with CRDTs and WebSockets; for the transport comparison this builds on, see WebSocket vs. SSE vs. long polling.
Why an unreliable channel is the right choice
A WebSocket runs over TCP, which guarantees ordered, reliable delivery — every byte arrives, in the order sent, or the connection stalls until it does. For a chat app or a turn-based game, that's exactly right: you never want message 5 to render before message 4. For a real-time multiplayer game sending position updates 20-60 times per second, that guarantee actively hurts: TCP's retransmission-and-reorder behavior means one lost packet head-of-line-blocks every packet behind it, so a single dropped position update can freeze the whole stream for one to two round trips while TCP recovers it — and by the time it arrives, three newer position updates have already been generated and are now stale.
WebRTC's RTCDataChannel runs over SCTP inside DTLS, and critically exposes ordered: false and maxRetransmits: 0 as configuration — an unordered, unreliable mode where a lost packet is simply gone, and the next one arrives as soon as it's ready, with no blocking. For position updates, that's correct: you want the latest state, and an update from three frames ago that finally arrives late is worse than useless, it's actively wrong. Reserve a second, reliable channel (ordered: true) for events that must not be dropped — a player firing a weapon, joining, or disconnecting — and keep position/velocity updates on the unreliable channel.
Quick reference
- Configure two data channels per peer connection: one unreliable/unordered for high-frequency state (position, rotation), one reliable/ordered for discrete events (fire, join, chat).
RTCDataChannel({ ordered: false, maxRetransmits: 0 })gives UDP-like fire-and-forget delivery — the closest the browser gets to raw UDP for game state.- TCP head-of-line blocking is invisible on a good connection and brutal on a lossy one — test over throttled/lossy network profiles, not just localhost.
- WebSockets remain the right choice for turn-based games, chat, and anything where losing or reordering a message is a correctness bug, not just a stale-frame cosmetic issue.
Remember this
An unordered, unreliable data channel is correct for high-frequency game state because the newest update matters more than every update arriving — TCP's ordering guarantee actively works against that goal.
Signaling and NAT traversal — how two players find each other
WebRTC connects two peers directly, but neither peer has a public address to connect to before the connection exists — that's the chicken-and-egg problem signaling solves. A signaling server (a plain WebSocket or HTTP server you run — WebRTC intentionally doesn't specify this part) relays each peer's session description (SDP: codecs, media/data capabilities) and ICE candidates (possible network paths: local IP, public IP via STUN, relay via TURN) to the other peer, so each side can attempt to open a direct connection once it has learned the other's addresses.
Most home and corporate NATs let a direct peer-to-peer connection through once both sides have exchanged the right candidates — a STUN server tells a client its own public IP:port as seen from outside its NAT, which is usually enough for the two peers to punch through to each other. Some NAT configurations (symmetric NAT, restrictive corporate firewalls) never allow a direct path, and the connection falls back to a TURN server, which relays every packet between the two peers — functionally identical to a game server, but at TURN-relay latency and bandwidth cost instead of direct peer latency. Budget for TURN fallback in production: a meaningful fraction of real-world connections (often cited around 10–20% depending on network mix) cannot connect peer-to-peer and need it.
Quick reference
- The signaling server only relays SDP and ICE candidates — it never touches game traffic once the peer connection is established, so it can be lightweight.
- Always configure at least one STUN server (free, public ones exist) and one TURN server (you must run or pay for this — it relays real bandwidth).
- ICE candidate gathering can take a few hundred milliseconds; show a "connecting…" state rather than assuming the data channel opens instantly.
- For more than two players, use an SFU-style mesh carefully — full mesh peer connections scale as O(n²) and become impractical past roughly 4-6 direct peers.
Remember this
Signaling exchanges addresses, not game data — STUN resolves most direct connections, and TURN relay is a required fallback, not an edge case, for a meaningful share of real networks.
Client prediction hides the latency you can't remove
Even a direct peer connection has real network latency — 40-100ms is typical between two home connections on different continents' edges of a region. If a player's own input only visibly moves their character after a round trip to the peer and back, the game feels laggy even though the network is working correctly; the fix is client-side prediction: the local client applies its own input to its own character immediately, without waiting for any network round trip, while also sending that input to the remote peer(s).
The remote peer receives the position update slightly late and needs to reconcile: for the other player's character, simple interpolation between the last two received states is enough (render slightly in the past, smoothly). For your own character if a server-authoritative correction ever disagrees with your prediction (common in server-relayed architectures, less so in pure peer-to-peer where there's no arbiter), reconciliation means snapping to the corrected state and replaying any inputs sent after that state was generated — the same technique competitive shooters use over WebSockets to servers, applied here at the peer level.
Quick reference
- Predict your own input locally and immediately; never wait for a network round trip to move your own character.
- Interpolate remote players' positions between the last two received updates rather than snapping — snapping on every 50ms update looks visibly choppy even on a good connection.
- If any peer is authoritative (a host-player model) and its correction disagrees with your prediction, reconcile by replaying unacknowledged local inputs from the corrected state, not by discarding prediction entirely.
- Timestamp every position update so interpolation and reconciliation both operate on relative time, not arrival order — arrival order is exactly what the unreliable channel does not guarantee.
Remember this
Predict your own input locally so it feels instant, interpolate remote players between their last two known states, and reconcile only when an authoritative correction actually disagrees with your prediction.
When neither STUN nor TURN gets you connected
The realistic failure: two players are both behind restrictive corporate firewalls that block the UDP ports WebRTC needs, and even the TURN relay (which typically needs a reachable UDP or TCP port) can't establish a session — pc.connectionState stays "connecting" and then transitions to "failed" after ICE gathering times out, typically 20-30 seconds by default. A game that just spins on "connecting…" forever gives the player no actionable information and looks broken rather than explained.
The recovery: set an explicit client-side timeout shorter than the browser's own ICE timeout (10-15 seconds is reasonable for a game lobby), and on connectionState === "failed" or your own timeout firing, surface a specific message ("Could not establish a direct connection — this can happen on restrictive networks") with a fallback path, which for many production games means a server-relayed mode (send state through your own backend over a WebSocket instead of peer-to-peer) rather than leaving the player stuck. This mirrors the broader lesson in graceful degradation patterns: a hard network dependency needs an explicit degraded mode, not just a longer timeout.
Quick reference
- Set your own client-side connection timeout shorter than the default ICE gathering timeout so the UI can react before the browser gives up.
pc.oniceconnectionstatechangeandpc.onconnectionstatechangeare the events to watch —"disconnected"can recover on its own briefly,"failed"will not.- A TURN-over-TCP-443 fallback (indistinguishable from ordinary HTTPS traffic to a restrictive firewall) recovers some connections that UDP-only TURN cannot.
- For a game that must work on any network, a fully server-relayed mode (no P2P attempt at all) is a legitimate default for some player populations, not just a fallback.
Remember this
Treat connectionState === "failed" as an expected outcome on a meaningful fraction of real networks — give the player a specific message and a server-relayed fallback, not an indefinite spinner.
Key takeaway
Build two browser tabs that open a signaling WebSocket to a small Node relay server, exchange SDP offer/answer and ICE candidates, and establish an unordered/unreliable data channel sending {x, y, t} position updates 20 times a second with local prediction rendering your own square instantly and interpolating the other tab's square between its last two updates. Expected result: moving your square feels instant regardless of a simulated 100ms delay you add to the signaling relay, and the other tab's square moves smoothly rather than snapping. Then break it — throttle one tab's network to drop 30% of packets (Chrome DevTools network conditioning) and confirm the game keeps running smoothly rather than stalling, since the unreliable channel simply skips lost updates. Finally, force a TURN-only path by disabling host and STUN candidates in your ICE config, and verify the connection still completes (at higher, relay-added latency) instead of failing outright. Pass criterion: local movement never waits on the network, remote movement stays smooth under 30% packet loss, and the TURN-forced path still reaches connectionState === "connected".
Related Articles
Explore this topic