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:
- Who owns shared truth?
- What are the command and outcome events?
- What does every payload guarantee?
- What snapshot does a new client receive?
- How are missed or duplicate events detected?
- What runs on the critical path?
- What happens when secondary work fails?
- How does identity survive reconnection?
- How will multiple server instances share state?
- 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.