HOME/BLOG/TECHNICAL
TECHNICAL

Real-Time Multiplayer Architecture with Socket.IO and Next.js

June 2, 202611 MIN READBy Diwanshu Gupta

A practical guide to the architecture behind Devception: Socket.IO rooms and reconnection, why Vercel serverless fights long-lived sockets, CRDT vs OT for the shared editor, and server-authoritative anti-cheat.

What makes real-time multiplayer hard

A normal web app reasons about one user's state at a time. A real-time multiplayer app has to keep many users' views of a shared world consistent, over connections with unpredictable latency, while people disconnect and rejoin mid-session. Devception adds a brutal extra constraint: several of those users are editing the same document simultaneously. This post is the architecture I landed on, and the trade-offs behind each decision.

Transport and rooms: Socket.IO

The connection layer is Socket.IO. Under the hood it uses WebSockets where available and adds the things you would otherwise rebuild yourself: automatic reconnection, heartbeats, and a rooms abstraction. Each match is a room. Joining is a few lines:

// server
io.on('connection', (socket) => {
  socket.on('joinMatch', (roomId) => {
    socket.join(roomId);
    socket.to(roomId).emit('playerJoined', socket.id);
  });

  socket.on('codeUpdate', (roomId, update) => {
    // relay the Yjs update to everyone else in the room
    socket.to(roomId).emit('codeUpdate', update);
  });
});

Broadcasting to a room instead of to individual sockets removes a whole category of routing bugs — you never maintain your own list of who is in a match.

Why the socket server is not on Vercel

This trips up a lot of Next.js developers, so it is worth being explicit. The Devception front end is Next.js 14 deployed on Vercel, but the Socket.IO server runs on a separate, always-on Node/Express process. The reason is structural: Vercel's serverless functions are short-lived and stateless, which is the opposite of what a long-lived, stateful WebSocket connection needs. Vercel's own guidance says serverless functions are not designed to maintain persistent socket connections and points you to a dedicated server or a managed realtime service for that workload (Vercel: WebSocket connections). So: static and server-rendered pages on Vercel, real-time on a persistent Node host. Keep them separate and life is much easier.

Reconnection and state resync

On a flaky connection, players will drop. The design has to treat reconnection as normal, not exceptional. I make the server authoritative over game state, so when a client reconnects within the grace window the server replays current state to just that socket rather than trying to diff what they missed. The Yjs document is the source of truth for the editor contents; the server is the source of truth for roles, progress, timers, and votes.

The shared editor: CRDT, not OT

For the editor itself I use a CRDT via Yjs rather than operational transformation. Both can converge concurrent edits, but they make different bets. Operational transformation transforms operations against each other and is famously hard to implement correctly. CRDTs give every character a unique identifier so concurrent operations are commutative by construction and need no central transform step (Shapiro et al., 2011). For a small team, "use a hardened CRDT library" beats "hand-write and debug OT" every time. Yjs binds directly to the Monaco editor, so the editor that powers VS Code becomes collaborative with very little glue.

Optimistic updates with a server-authoritative backstop

Latency above ~100ms feels laggy in an interactive editor, so the client applies local edits immediately (optimistic update) and reconciles when the authoritative update arrives. That is fine for ordinary text. It is not fine for privileged actions — which is the whole anti-cheat story.

Because imposter abilities are powerful, the client is never trusted to declare them. The server holds the secret role assignments and validates every privileged action before applying and broadcasting it:

socket.on('sabotage', (roomId, action) => {
  const match = matches.get(roomId);
  const player = match.players.get(socket.id);

  // never trust the client about its own role
  if (player.role !== 'imposter') return;
  if (!offCooldown(player, action)) return;

  applySabotage(match, action);
  io.to(roomId).emit('sabotageApplied', publicView(action));
});

The rule of thumb: optimistic on the client for responsiveness, authoritative on the server for anything that affects fairness.

Latency tactics that actually mattered

  • Debounce, don't flood. Yjs already batches document updates; relaying coalesced updates beats emitting on every keystroke.
  • Selective broadcasting. Not every event needs every client. A task completion only has to move the progress bar — it does not need a full state sync.
  • Separate channels by volatility. High-frequency editor traffic and low-frequency game events (votes, meetings) are distinct event types, so one cannot starve the other.

The topology, in one picture

Browser (Next.js app + Monaco + Yjs client) talks over Socket.IO to a persistent Node/Express server that owns match state and relays Yjs updates; the marketing and content pages (this blog, About, legal) are plain server-rendered Next.js on Vercel. Two deployment targets, each doing what it is good at.

The takeaway

Real-time multiplayer is a stack of individually-hard problems — convergence, reconnection, latency, and trust — that multiply when combined. None of the choices here are exotic; they are the boring, correct options (rooms, CRDTs, a real server for sockets, server-side validation) applied deliberately. For a collaborative coding game, boring and correct is exactly what you want.

References

TAGS:#socket.io#next.js#websockets#crdt#real-time#architecture
👨‍💻
Diwanshu Gupta
FOUNDER & LEAD DEVELOPER

Diwanshu Gupta is the founder and lead developer of Devception. He works mostly in the messy intersection of real-time systems, game design, and developer tooling — and built Devception to make practicing code feel like a team sport instead of a solo grind. More about the team →

READY TO EXPERIENCE THIS YOURSELF?

Join a match and see how social deduction changes coding forever.