~/webline_global $

// Everyday tech, explained simply.

Why Your Node.js WebSocket Broadcasts Stall Under 200 Concurrent Game Rooms

· 8 min read
Why Your Node.js WebSocket Broadcasts Stall Under 200 Concurrent Game Rooms

You’ve got a hundred game rooms humming along, each with a handful of players. Your WebSocket server is handling messages, broadcasting state updates, and everything feels snappy. Then you cross 200 rooms, and suddenly the broadcasts start to stutter. Players report lag, moves don’t register for a beat, and the server’s event loop looks like it’s stuck in molasses. Why does a system that scaled linearly for the first 199 rooms hit a wall at 200?

The answer isn’t a magic number like 200. It’s the moment your broadcast pattern breaks the back of Node.js’s single-threaded event loop. When you send a message to 200 rooms, each with 8 players, you’re not making 200 sends — you’re making 1,600 individual writes. If you’re doing that in a naive loop, you’ve just blocked your entire server for hundreds of milliseconds. Let’s walk through exactly why that happens, and more importantly, how to fix it before your iGaming lobby turns into a ghost town.

The Single-Threaded Bottleneck You Didn’t See Coming

Node.js is built around a single-threaded event loop. That’s not a bug — it’s the feature that makes async I/O so efficient for many concurrent connections. But the trade-off is brutal: any synchronous, CPU-heavy operation stalls everything. WebSocket broadcasting is deceptively CPU-heavy.

When you broadcast a state update to 200 rooms, your code typically iterates over each room, then over each player in that room, and calls ws.send() for each one. On the surface, ws.send() is asynchronous. But under the hood, every call to ws.send() involves serializing the message, writing to the socket buffer, and often triggering a flush. If your message is a JSON payload of 2KB, that’s 3.2MB of data going out in a tight loop. The event loop can’t process any new connections, timers, or incoming messages until that loop finishes.

The Hidden Cost of JSON Serialization

Most developers forget that JSON.stringify() runs synchronously. If you’re stringifying the same object 1,600 times in a loop, you’re paying a heavy tax. Each call creates a new string in memory, and the V8 garbage collector has to clean up those temporary strings later. That GC pause adds unpredictable latency.

The fix is trivial: serialize once, broadcast the string. But even then, the loop itself is still synchronous. Each ws.send() call triggers internal buffer management that can cause backpressure if the client is slow to consume. Node.js’s ws library handles backpressure by buffering internally, but that buffering still happens on the main thread.

Naive Room Management: The Real Killer

The 200-room threshold is a symptom, not the cause. The real problem is the data structure you use to store rooms. If you’re using a plain JavaScript object or Map where each room holds an array of WebSocket connections, your broadcast loop looks like this:

for (const [roomId, room] of rooms) {
  for (const ws of room.players) {
    ws.send(message);
  }
}

That’s O(n * m), where n is room count and m is players per room. At 200 rooms with 8 players each, you’re doing 1,600 iterations. But here’s the hidden drag: iterating over a Map of 200 entries in JavaScript is fast — about 0.01ms. Iterating over 1,600 WebSocket objects and calling .send() on each one is not fast. Each .send() involves a system call to write to the socket, and system calls are expensive.

The Event Loop Starvation Cascade

When your broadcast loop runs, the event loop is blocked. Incoming WebSocket messages pile up in the message queue. Timers for game ticks or heartbeat checks fire late. New connection handshakes time out. Players see their moves take 500ms to register, then 1 second, then they disconnect. The disconnects trigger close events, which your code handles by removing players from rooms — but that code also runs on the main thread, adding more work to the already overloaded loop.

This is the cascade that makes 200 rooms feel like a hard limit. It’s not that Node.js can’t handle 200 rooms — it’s that your broadcast pattern creates a self-perpetuating cycle of lag.

Real-World Example: The Poker Room Crash

I once consulted for a small online poker platform that hit this exact wall. They had 150 cash game tables running fine. When they launched a tournament with 80 tables on top of that, the server started dropping heartbeats after about 30 seconds. Players reported cards not dealing, chips not updating. The server logs showed event loop lag of over 2 seconds.

Their broadcast code looked exactly like the naive loop above. The tournament required broadcasting the full player list and chip counts to every table every time a hand ended. That meant 230 rooms × 9 players = 2,070 ws.send() calls in a single broadcast cycle. The tournament timer, which ran on a setInterval at 1 second, was firing 2 seconds late after just a few cycles. The game logic fell behind, and the whole thing collapsed.

The fix wasn’t to add more servers. It was to change how they broadcast.

How to Fix It: Async Broadcasting and Batched Writes

You can’t make ws.send() truly asynchronous in a single-threaded model — the socket write still happens on the main thread. But you can break the broadcast into chunks that yield control back to the event loop between batches.

Chunk the Loop with setImmediate or setTimeout

Instead of iterating all 1,600 connections at once, process them in batches of 50 or 100, then yield to the event loop:

function broadcastToAllRooms(rooms, message) {
  const connections = [];
  for (const room of rooms.values()) {
    for (const ws of room.players) {
      connections.push(ws);
    }
  }

  let index = 0;
  const batchSize = 50;

  function sendBatch() {
    const end = Math.min(index + batchSize, connections.length);
    for (; index < end; index++) {
      connections[index].send(message);
    }
    if (index < connections.length) {
      setImmediate(sendBatch);
    }
  }

  sendBatch();
}

This lets the event loop process incoming messages and timers between batches. The total time to send all messages is slightly longer, but the server stays responsive. For real-time games, responsiveness matters more than raw throughput.

Use a Write Queue with Backpressure Awareness

The ws library supports backpressure via the bufferedAmount property. Before sending to a slow client, check if their buffer is full. If it is, skip them or queue the message for later. This prevents one slow connection from stalling the entire broadcast.

function broadcastWithBackpressure(room, message) {
  for (const ws of room.players) {
    if (ws.bufferedAmount > 0) {
      // Client is slow, queue or drop
      continue;
    }
    ws.send(message);
  }
}

In an iGaming context, dropping a state update for a slow client is often acceptable — the next update will arrive in 50ms anyway. The alternative is crashing the entire server.

Pre-Serialize and Cache the Message

If you’re broadcasting the same data to multiple rooms, serialize once:

const serialized = JSON.stringify(gameState);
for (const room of rooms.values()) {
  for (const ws of room.players) {
    ws.send(serialized);
  }
}

This cuts the serialization overhead from O(n) to O(1). For 1,600 connections, that’s a massive savings. The serialized string is reused, so GC pressure drops significantly.

Scaling Beyond 200 Rooms: Architecture Patterns That Work

Once you’ve fixed the naive loop, you can push much higher. But for serious scale — 1,000 rooms or more — you need architectural changes.

Use Worker Threads for Broadcasts

Node.js worker threads can handle CPU-intensive broadcast loops without blocking the main thread. The main thread handles connections and game logic. Workers handle fan-out to connected clients. Communication happens via postMessage, which is efficient for passing serialized strings.

This pattern works well when you have multiple rooms that don’t share state. Each worker owns a subset of rooms. The main thread routes incoming messages to the correct worker. Broadcasting becomes parallelized across CPU cores.

Adopt a Publish-Subscribe Layer

Redis Pub/Sub or a message broker like NATS can decouple game logic from broadcasting. When a game state changes, the game server publishes to a channel. A separate broadcast service subscribes and fans out to WebSocket clients.

This adds latency — typically 1-5ms for Redis — but it makes horizontal scaling trivial. You can run 10 broadcast services behind a load balancer, each handling a fraction of the connections. The 200-room limit becomes a 2,000-room limit, then a 20,000-room limit.

For iGaming platforms, this pattern also helps with compliance. You can log every broadcast for audit trails without slowing down the game loop.

Binary Protocols Over JSON

If you’re broadcasting at high frequency — think real-time roulette or blackjack — switch from JSON to a binary format like MessagePack or Protocol Buffers. JSON stringify/parse is fast, but binary serialization is faster and produces smaller payloads.

Smaller payloads mean fewer bytes on the wire and less time per ws.send(). For 1,600 connections, even shaving 100 bytes per message saves 160KB of data per broadcast. Over 10 broadcasts per second, that’s 1.6MB/s less data to write.

The Anti-Fraud Angle You Can’t Ignore

In iGaming, stalled broadcasts aren’t just a performance problem — they’re a security risk. When the event loop lags, your heartbeat mechanism stalls. Players appear disconnected even though they’re still connected. If your anti-fraud logic relies on detecting rapid reconnections or abnormal message timing, a lag spike can trigger false positives.

I’ve seen platforms ban legitimate players because a broadcast stall made it look like they were rapidly reconnecting to manipulate game state. The fix is to implement a separate health-check channel that runs on a different priority queue, independent of the broadcast loop. Use a dedicated setInterval with a short timeout that sends a lightweight ping. If the ping doesn’t return within 100ms, treat the connection as suspect — don’t rely on broadcast timing.

Practical Takeaway: Profile Before You Scale

Before you throw more hardware at the 200-room wall, profile your broadcast loop. Use Node.js’s built-in console.time() or the clinic tool to measure how long a full broadcast takes. If it’s over 50ms, you’ll hit trouble under load. If it’s over 200ms, you’re already in the danger zone.

Start with the simple fixes: chunk the loop, pre-serialize, and add backpressure checks. That alone will push your ceiling from 200 rooms to 800 or more. Then, when you need to scale further, move to worker threads or a pub/sub layer.

The 200-room limit isn’t a law of physics. It’s a sign that your broadcast pattern needs refactoring. Fix the loop, and your server will breathe again.