In a live race, the leaderboard is the product. If it lags by five seconds, the runner in second place sees themselves in first, the spectator map shows someone running through a building, and the whole thing feels broken even when every number is eventually right. Real-time leaderboard WebSockets architecture is a simple idea (phones send positions, a server ranks them, everyone gets the ranking) with most of the difficulty in what happens when phones misbehave.

Kompete, the running app we built, has a compete mode where racers are ranked live, and spectator maps that show everyone moving. It’s Flutter on the phone, with high-frequency GPS and live position broadcasting, and a Node.js backend. This post is about the server side and the protocol, and none of it is Flutter-specific.

Real-time leaderboard WebSockets design: who owns the ranking

The first decision: the server ranks, never the clients. Each runner’s phone sends its own progress (position, cumulative distance, elapsed time). The server keeps the race state, computes the order, and broadcasts it. Clients just render what they’re told.

That gives you one source of truth, which matters when two runners are a few metres apart and their phones disagree about who passed whom. It also gives you one place to apply sanity checks. A phone reporting a 2-minute kilometre means a GPS glitch or someone in a car. Cap plausible speed per sport and ignore points that break it.

Rank by the thing the race is actually about. In a distance race that’s distance covered (ties broken by who reached it first). In a timed challenge it’s distance at the cutoff. A challenge mode like Kompete’s, where you send your exact result to someone as a target, fits the same model: one live runner and one recorded run, with the recording replayed against elapsed time and ranked like any other racer.

Broadcast on a tick, not on every message

The naive design forwards every incoming position to every connected client. With 50 runners sending once a second and 200 spectators, that’s 10,000 messages a second for one race, most of them out of date by the time they arrive.

Decouple input from output instead. Incoming positions update in-memory state and nothing else. A fixed tick (once a second is plenty for a running race) computes the ranking and sends one snapshot per race to each subscriber:

setInterval(() => {
  for (const race of races.values()) {
    race.seq += 1;
    const msg = JSON.stringify({
      type: 'board', seq: race.seq, board: rank(race),
    });
    for (const ws of race.subscribers) {
      if (ws.bufferedAmount < 64 * 1024) ws.send(msg);
    }
  }
}, 1000);

Two details in that loop matter more than they look:

  • The sequence number. Clients drop any message with a seq lower than the last one they rendered. Out-of-order delivery after a reconnect stops being a bug.
  • The bufferedAmount check. A spectator on a weak connection can’t keep up. Without the check, the server buffers messages for them until memory runs out. With it, a slow client just skips a frame, and because each message is a full snapshot, skipping is harmless. Stale leaderboard frames are worthless anyway.

Spectators and racers don’t need the same data. Racers need their own position and the few people around them. Spectators need everyone’s position for the map. Send each group what it renders, at the rate it needs.

Smooth maps from coarse updates

A spectator map updated once a second looks jerky if markers jump. Animate each marker from its last position to its new one over the tick interval on the client. It costs nothing on the server, and with a little interpolation one update a second looks like continuous motion.

When phones disconnect

They will. Runners go through underpasses, switch from Wi-Fi to mobile data, and lock their phones. Plan for three cases:

  1. Brief drops. The runner’s app keeps recording GPS locally (its background location keeps working without a network) and buffers the points it couldn’t send. On reconnect it uploads the backlog, and the server replays it into the race state in timestamp order. The runner’s position jumps forward, which is correct.
  2. Spectator reconnects. On connect, the server sends a full snapshot first, then normal ticks. Never make a client rebuild state from deltas it may have missed.
  3. Dead connections that look alive. A mobile connection can die without a close frame. Use WebSocket ping and pong on a short interval, and drop sockets that miss a few. Otherwise your subscriber lists fill with ghosts, and every tick wastes work on them.

Separate “connected” from “in the race”. A runner whose socket dropped 20 seconds ago is still in the race, just stale. Show it on the leaderboard (a greyed row, a “last seen” time) rather than removing them and making the order jump around.

Scaling past one server

A single Node process with in-memory race state goes a long way, and it’s much easier to reason about. When you need more than one, the usual move is to keep race state in a shared store (a Redis sorted set fits leaderboards naturally) and have each server publish ticks through pub/sub, or to route every connection for a given race to the same server. Do the second if you can. It keeps the tick loop above unchanged.

At orithLabs we build live features like this for fitness, race and logistics apps, on the phone and on the server. If you’re planning something real-time, our mobile development page shows how we work, and the Kompete case study shows a live leaderboard in production.