Skip to content
All notes

A WebSocket Connection Is Not a Real-Time Architecture

The transport moves messages. Your protocol decides whether the product stays coherent.

Systems design3 min read
Contents

Connecting two browsers with WebSockets can take a few lines of code.

socket.emit("move", payload);
socket.on("move", renderMove);

That proves messages can travel. It does not prove both browsers agree on the state of the product.

The architecture begins after the connection opens.

Start with authority

For every shared action, decide which process is allowed to turn a request into truth.

In a two-player chess game, the browser can check whether e2 to e4 looks legal. That is useful for fast feedback. It is not enough for shared state because a browser can be stale or modified.

The server should own the accepted transition:

request → membership → permission → current state → domain validation → save → broadcast

This is not only an anti-cheat rule. It prevents two honest clients from diverging when they act on different snapshots.

Name events by what happened

An event is easier to maintain when its name describes one fact.

Weak:

update
data
message
sync

Useful:

room-updated
move
commentary
invalid-move

The receiver can tell whether it is handling a snapshot, an accepted domain event, a secondary side effect, or a rejection.

Avoid one giant event whose payload changes shape based on a type field unless you have a clear envelope and schema. Separate events make logging, testing, and access control easier to reason about.

Define payloads like API contracts

A socket event deserves the same discipline as an HTTP endpoint.

Document:

  • direction;
  • required fields;
  • types and constraints;
  • who is allowed to send it;
  • who receives it;
  • state mutation;
  • success response;
  • rejection behavior;
  • ordering relative to other events.

For example:

socket.emit("move", {
  roomId: "abc123",
  move: {
    from: "e2",
    to: "e4",
    promotion: "q",
  },
});

The important part is not the JSON shape alone. The contract says the server checks that the socket owns a seat, that its color has the turn, and that the move is legal before broadcasting anything.

Separate snapshots from the live stream

Late joiners and reconnecting clients need a state baseline. A stream of events is only useful if the receiver knows where to start.

Use both:

  • a snapshot for current state;
  • domain events for changes after that snapshot.

In Real-Time Chess, room-updated carries the room's FEN, players, status, history, and commentary after create or join. move and commentary handle the live path.

The next maturity step is a version number:

{
  roomId: "abc123",
  version: 42,
  fen: "...",
}

Every event can then state which version it produces. A client that sees 40 → 42 knows it missed something and should request a fresh snapshot.

Ordering is a product decision

WebSockets preserve message order on one connection, but your application still creates asynchronous branches.

Suppose a chess move triggers model-generated commentary. If the server waits on the model before sending the move, a secondary feature controls gameplay latency.

The better product contract is:

save move
emit move
generate commentary
save commentary
emit commentary

Now the board is responsive and the commentary panel can communicate its own pending state.

Ordering should be written down because it determines what the user sees when one part is slow or fails.

Rooms are routing, not authorization

Socket.IO rooms are a useful fan-out primitive. They answer: which connected sockets should receive this event?

They do not automatically answer:

  • is this socket allowed to join?
  • is this socket allowed to issue this command?
  • does this socket still own the seat?
  • can this payload mutate the room?

Authorization belongs at the command boundary. Room membership only narrows delivery.

Reconnection requires identity

A socket ID identifies a transport connection, not a person or durable client.

If identity is socket.id, refreshing the page creates a new player. A production protocol needs something stable:

player ID + signed room seat token + current socket ID

On reconnect, the server validates the token, replaces the old socket mapping, and sends the current versioned snapshot.

Without that layer, "reconnect support" often means "the browser connected again," not "the user recovered their session."

Test the conversation, not just the functions

Unit tests for handlers are useful, but real-time bugs often live between events.

An end-to-end verifier should connect multiple clients and assert:

  • who receives each event;
  • event order;
  • accepted and rejected commands;
  • snapshot shape;
  • behavior after disconnect;
  • timeout and fallback paths.

The test is a conversation between clients and server. Treat it that way.

The checklist I now use

Before calling a WebSocket feature complete, I want clear answers to:

  1. Who owns shared truth?
  2. What are the command and outcome events?
  3. What does every payload guarantee?
  4. What snapshot does a new client receive?
  5. How are missed or duplicate events detected?
  6. What runs on the critical path?
  7. What happens when secondary work fails?
  8. How does identity survive reconnection?
  9. How will multiple server instances share state?
  10. Which multi-client flow proves the contract?

A persistent connection is transport. The real architecture is the set of decisions that keeps the product coherent while messages move through it.

Notes from the build

Get more AI engineering insights

Follow the work: AI tools, browser products, product decisions, and honest lessons from the build.

By subscribing, you agree to receive Orlando's emails. No spam. Unsubscribe anytime.

A WebSocket Connection Is Not a Real-Time Architecture | Orlando Ascanio