Skip to main content
Back to Blog
9 min read

Real-time WebSocket patterns for ticketing and chat: a production playbook

Patterns from two production systems: a ticketing platform handling 12K concurrent seat selections and a team chat with presence indicators. What worked, what didn't, and the architecture behind both.

next.jsperformancecase-study

I've shipped two WebSocket-heavy production systems: the E7 ticketing platform (real-time seat map updates during high-demand on-sales) and a team collaboration tool with live presence and messaging. Both taught me different things about real-time architecture.

This post covers the patterns that survived production — not the textbook patterns, but the ones that actually work when 3,000 people are fighting over the same seats or 200 team members need instant message delivery.

Pattern 1: The room model

Both systems use rooms. A room is a logical grouping of WebSocket connections that share state.

Ticketing: One room per event on-sale. When a user opens the seat map for Event #42, they join room event:42. Every seat lock, unlock, and purchase in that event broadcasts to everyone in the room.

Chat: One room per channel. When a user opens #engineering, they join room channel:engineering. Every message, edit, and typing indicator broadcasts to everyone in the room.

The room model solves the fan-out problem: you only send updates to connections that care. Without rooms, every seat lock would broadcast to every connected user across every event — 99% wasted bandwidth.

// Simplified room management
const rooms = new Map<string, Set<WebSocket>>()

function joinRoom(ws: WebSocket, roomId: string) {
  if (!rooms.has(roomId)) rooms.set(roomId, new Set())
  rooms.get(roomId)!.add(ws)
}

function broadcastToRoom(roomId: string, message: object, exclude?: WebSocket) {
  const room = rooms.get(roomId)
  if (!room) return
  const payload = JSON.stringify(message)
  for (const ws of room) {
    if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
      ws.send(payload)
    }
  }
}

Lesson learned: Keep rooms small. The E7 platform had events with 3,000 concurrent users in one room. At that scale, a single seat lock generates 3,000 outbound messages. We optimized by batching: instead of broadcasting every individual seat lock, we batch updates every 200ms and send one message with all changes in that window. This reduced outbound messages by ~80% during peak on-sales.

Pattern 2: Optimistic updates with server reconciliation

Both systems use the same client-side pattern: apply the change immediately in the UI, send it to the server, and reconcile if the server disagrees.

Ticketing seat lock:

  1. User taps a seat → UI shows it as "yours" (green) immediately
  2. Client sends { type: "lock_seat", seatId: "A-15" } via WebSocket
  3. Server checks: is A-15 available? If yes → confirms lock, broadcasts to room. If no → sends rejection back to the requesting client
  4. On rejection → client reverts the seat to "available" with a haptic buzz

Chat message:

  1. User sends a message → UI renders it immediately with a "sending" indicator
  2. Client sends { type: "message", channelId: "engineering", text: "..." } via WebSocket
  3. Server persists the message, assigns an ID and timestamp, broadcasts to room
  4. Client receives the server-confirmed version and updates the local message (replacing the temp ID with the real one, adding the server timestamp)

The key insight: optimistic updates hide latency, server reconciliation ensures correctness. The user perceives zero delay. The server is the source of truth. If the network drops between step 2 and 3, the client's retry logic re-sends.

What went wrong initially: In the ticketing system, we didn't have a timeout on the optimistic lock. A user would tap a seat, the WebSocket message would fail silently (connection had dropped), and the seat would stay green on their screen forever — locked on the client, available on the server. Fix: every optimistic update has a 3-second timeout. If no server confirmation arrives, the client reverts the update and shows a "connection lost" banner.

Pattern 3: Heartbeat and reconnection

WebSocket connections die silently. The TCP connection can be half-open (server thinks it's alive, client is gone) or the client can lose network without the close frame being sent.

Both systems use a heartbeat:

// Server-side heartbeat
const HEARTBEAT_INTERVAL = 30_000 // 30 seconds
const HEARTBEAT_TIMEOUT = 10_000 // 10 seconds to respond

function setupHeartbeat(ws: WebSocket) {
  let isAlive = true

  ws.on('pong', () => {
    isAlive = true
  })

  const interval = setInterval(() => {
    if (!isAlive) {
      ws.terminate() // Connection is dead
      return
    }
    isAlive = false
    ws.ping()
  }, HEARTBEAT_INTERVAL)

  ws.on('close', () => clearInterval(interval))
}

Client-side reconnection uses exponential backoff with jitter:

function connect() {
  const ws = new WebSocket(url)

  ws.onclose = () => {
    const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000)
    const jitter = delay * 0.5 * Math.random()
    setTimeout(connect, delay + jitter)
    reconnectAttempts++
  }

  ws.onopen = () => {
    reconnectAttempts = 0
    rejoinRooms() // Re-subscribe to rooms after reconnect
  }
}

The jitter is critical. Without it, if a server restarts and 3,000 clients reconnect simultaneously, they all retry at the same intervals — creating a thundering herd that crashes the server again. Jitter spreads the reconnections over time.

Lesson from ticketing: After a reconnect, the client must request the current state of the seat map — not rely on cached state. During the disconnect, seats may have been locked, unlocked, or purchased. We send a sync_state message on reconnect that returns the full current state of all seats in the room. This is expensive (one full-state message per reconnecting client) but necessary for correctness.

Pattern 4: Presence

The chat system shows who's online and who's typing. This sounds simple but has subtle edge cases.

Online presence:

Typing indicator:

The gotcha: Presence requires a single source of truth for "who is connected." If you're running multiple WebSocket server instances behind a load balancer, each instance only knows about its own connections. You need a shared store (Redis pub/sub, or a dedicated presence service) to aggregate presence across instances.

For E7, we run a single WebSocket server (the traffic pattern is bursty — high during on-sales, near-zero otherwise — so horizontal scaling wasn't needed). For the chat system, we use Redis pub/sub to fan out messages and presence updates across 3 server instances.

Pattern 5: Message ordering and deduplication

Messages can arrive out of order or be duplicated (client retries after a timeout, but the original message actually arrived).

Ordering: Every message gets a server-assigned sequence number within its room. The client renders messages in sequence order, not arrival order. If message #42 arrives before #41, the client buffers #42 and renders both when #41 arrives.

Deduplication: Every client-sent message includes a client-generated idempotencyKey (UUID). The server checks this key before processing. If it's already been processed, the server sends back the existing result without re-processing.

// Client-side
function sendMessage(text: string) {
  const key = crypto.randomUUID()
  ws.send(
    JSON.stringify({
      type: 'message',
      text,
      idempotencyKey: key,
    })
  )
  // Store key for retry
  pendingMessages.set(key, { text, sentAt: Date.now() })
}

The ticketing system doesn't need message ordering (seat state is idempotent — the latest state is always correct) but critically needs deduplication (double-tapping a seat shouldn't create two lock requests).

Pattern 6: Graceful degradation

WebSockets fail. Firewalls block them. Corporate proxies strip the Upgrade header. Both systems have fallback modes:

Ticketing: Falls back to HTTP polling every 2 seconds. The seat map still updates, just with 2-second latency instead of real-time. We detect WebSocket failure on the client (connection fails 3 times) and silently switch to polling. About 3% of users end up on the polling path — mostly corporate networks and certain mobile carriers.

Chat: Falls back to long-polling. Less efficient than WebSockets but works through any HTTP proxy. The UX degrades slightly (typing indicators disappear, presence updates are delayed) but messages still deliver reliably.

The MVP shortcut: For the E7 MVP, we shipped polling-only for the first version. No WebSockets at all. The seat map updated every 3 seconds via HTTP. This was good enough for the first 3 months. We added WebSockets when event sizes grew and 3-second delays started causing double-bookings on popular seats.

This is the real tradeoff: polling is an MVP decision. WebSockets are a scale decision. Don't build real-time infrastructure before you need it.

The infrastructure

Ticketing (E7):

Chat:

Both systems use the same client library: A custom WebSocket wrapper (~200 lines) that handles reconnection, heartbeat, room management, and message queuing. Not a framework — just the patterns described above, composed together.

What I'd do differently

I'd use Server-Sent Events (SSE) for the ticketing system instead of WebSockets. The seat map is a one-way data flow: server pushes updates to clients. Clients send seat locks via regular HTTP POST requests. SSE is simpler (no upgrade handshake, works through proxies, automatic reconnection built into the browser), and the seat lock action doesn't need a persistent bidirectional channel — a POST request is fine.

I chose WebSockets because I assumed we'd need bidirectional communication. In practice, 95% of the traffic is server-to-client. SSE + HTTP POST would have been simpler to deploy and debug.

I'd evaluate Socket.IO more seriously for the chat system. I avoided it because of bundle size and "just use ws" bias. But Socket.IO handles reconnection, room management, and multiplexing out of the box — all things I built manually. For a chat system where those features are table stakes, the library overhead is worth it.


Building something that needs real-time updates? Let's figure out the right architecture — sometimes it's WebSockets, sometimes it's SSE, sometimes it's just polling with a 2-second interval.