Skip to main content
This page was machine-translated and may contain errors or omissions.

JWT and WebSockets

Token revocation and multiplayer state sync

Thu Jun 04 2026
6220 words · 33 minutes

Almost every project issues JWTs, yet few developers have pasted a real token into a decoder and read it field by field; and taking a single-player mini-game multiplayer is the same story — the difficulty never lies in “establishing the connection”, it starts after the connection is up. The first matter’s entire mechanism is two sentences: base64url-encode a JSON of claims, then sign it; the second splits into three questions: whose version is authoritative, what gets synced, and what happens when someone drops. This post first dissects a token’s three segments and settles the ledger of algorithm choice and revocation, then walks into the long-connection arena to watch the two sync schools divide the work and rooms survive every drop.

Token anatomy: three segments, signatures, and revocation

The shortest path to understanding all of it is pasting a real token into a decoder and reading it field by field. The encoding is plaintext, the signature is tamper-proofing, and beyond that there is no magic — once you get that, its security model, its selection criteria, and every failure point unfold on their own.

Hands-on versions: the JWT Parser, the Codec, the Security Reference.

Three segments and base64url

A JWT looks like this (the classic demo token on jwt.io):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three segments separated by dots: header (declares the signing algorithm), payload (the claims proper), and signature.

The first two segments are base64url-encoded. Note that this is not standard base64: the URL-safe variant swaps + for - and / for _, and drops the padding equals signs.

Hand-decoding trips up here easily. Feeding base64url straight into atob throws an error; the right move is to swap the characters back first, then pad the equals signs missing from length % 4.

Decoding the two segments above:

// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }

The fields in the payload are called claims, in two classes: registered claims and custom claims.

Registered claims are a set of conventional standard fields: iss (issuer), sub (subject, usually the user ID), aud (audience), exp (expiry time, Unix seconds), iat (issued at), nbf (not before), jti (token unique ID).

Custom claims can be added freely. That is lesson one: the payload is ordinary JSON, and anyone holding the token can open it.

base64 is encoding, not encryption; putting names, emails, or permission lists in it is broadcasting in plaintext, and putting sensitive information (passwords, card numbers, internal identifiers) in it is an incident.

While we’re here, three terms that often get mixed up: JWT, JWS, JWE.

JWT is the abstraction of a “claims container”. Wrap it signed and you have JWS (three segments — 99% of today’s “JWTs” are actually JWS). Wrap it encrypted and you have JWE (five segments, payload invisible to third parties).

When everyday conversation asks “is JWT secure”, it is almost always asking about JWS. It proves the content wasn’t altered, but doesn’t guarantee nobody looked; confidentiality always needs TLS or JWE alongside.

To inspect the structure without leaking the content, paste the token into the JWT Parser: it automatically decodes the three segments as base64url, pretty-prints the header and payload, and checks exp. While valid it shows the remaining time; once expired, it turns red.

If you want to do this encoding conversion by hand in other scenarios, the Codec offers a set of conversion tabs including base64 and URL codecs; the same base64url rules carry across the tools.

The evolution of the session problem

To understand why JWT is shaped the way it is, first look at what it solved.

The earliest login state was plain and direct: after login, the server issues a Session ID and keeps one row in its own memory or database — “this ID maps to this user”.

Problems arrive with scale. Session state lives on the server’s body, so every machine in the cluster must recognize the same session.

So sticky sessions (pinning the same user to the same machine, sacrificing load balancing) and centralized session stores (Redis — one more network round trip and a new single point of failure) took the stage in turn.

Mobile and open APIs twisted the knife: the Cookie system translates poorly to non-browser clients, and “how should credentials be carried, and carried cross-origin” became a universal problem.

JWT’s answer pulls the rug out from under: move the session state from the server into the token itself.

User ID, permissions, and expiry go straight into the payload, the signature guarantees none of it has been tampered with, and verification needs one key and zero storage queries. Any machine, any service holding the key can independently decide “who is this request”.

Horizontal scaling becomes trivially easy: there is no such thing as session sync anymore.

Put the two approaches side by side and the trade-offs are plain:

DimensionSessionJWT
State locationServer (memory/Redis)The token itself (held by client)
Immediate revocationDelete one rowNot possible; needs extra machinery
Horizontal scalingNeeds shared storage or sticky sessionsStateless by nature
Per-request costOne storage queryOne signature computation
Credential sizeAn ID of a few dozen bytesHundreds of bytes and up, swelling with claims
Cookie dependencyNaturally boundCan ride headers, Cookie-free

Note the last two rows: JWT’s credential size grows linearly with the number of claims, and every request has to haul it around.

A token with dozens of permission entries stuffed into its payload is a perceptible burden on weak mobile networks.

Every detail of this trade — what you gain, what you lose — runs through every remaining section of this chapter.

Choosing between HS256 and RS256

The third segment, the signature, is the essential difference between a JWT and “any old JSON slapped together”.

Signing means: the party holding the key has confirmed these two segments have not been tampered with.

It keeps nothing secret. Confidentiality is TLS’s job; the signature only does integrity checking and origin authentication.

The most common algorithm, HS256, is HMAC-SHA256.

The server holds one shared secret and computes a keyed hash over base64url(header) + "." + base64url(payload); the result becomes the third segment.

The verifier must be the key holder: recompute the signature and compare.

In the browser with Web Crypto, that takes a dozen-odd lines:

async function signHS256(header, payload, secret) {
const enc = (obj) =>
btoa(JSON.stringify(obj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const data = `${enc(header)}.${enc(payload)}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false, ["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
return data + "." + btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

RS256 is the asymmetric route: the private key signs, the public key verifies.

The “shape” of this key pair draws the boundary between the two choices:

DimensionHS256 (symmetric)RS256 (asymmetric)
Key structureSingle shared secretPrivate key signs + public key verifies
Verifier’s powerAnyone who can verify can signPublic key verifies only, cannot forge
Key distributionEvery verifier needs the secretPublic key can be public (JWKS endpoint)
Sign/verify costExtremely fast (hash-level)Verification slower than HMAC, signing slower still
Typical settingMonoliths, internal microservicesResource servers that don’t trust the issuer, third-party integration

The selection criterion in one sentence: does the verifier need to trust the issuer?

In a monolith, issuing and verifying are the same process, and HS256 is simple and fast.

Once resource servers and the auth center belong to different teams (even different companies), handing HS256’s shared secret to every verifier means bundling “ticket-checking rights” and “ticket-printing rights” together and giving both to everyone. Breach any single verification service and the attacker can mint any identity they like.

RS256’s public key can be published freely, and rotating keys through a JWKS endpoint comes naturally.

Under a microservices architecture, defaulting to RS256 (or an EdDSA algorithm like Ed25519 — faster signing, shorter keys) and leaving HS256 inside the trust boundary is the secure default posture.

While we’re here, put the elliptic-curve family back in view.

Compared with RSA, ES256 (ECDSA over P-256) and Ed25519 have one engineering-friendly property: signature and key sizes an order of magnitude smaller (an ES256 signature is about 64 bytes versus RSA-2048’s 256), real relief in the precious space of an HTTP header.

The price is ECDSA’s extreme sensitivity to randomness (reuse k once and the private key leaks), so implementations must lean on libraries rather than hand-rolling.

The selection order can therefore be memorized as: EdDSA first, ES256 next, RS256 when maximum compatibility is needed, and HS256 only inside the trust boundary.

The second tab of the JWT Parser is an issuing lab: prefilled with the demo payload above, it lets you edit the header, payload, and secret, pick a signature, and generate a complete token directly.

You can also switch to verification mode to check whether the signature matches. Signing one yourself, then tampering one character of the payload and verifying, teaches “the signature binds byte-level content” better than reading ten documents.

Signing one by hand

Walk the issuance flow byte by byte and the remaining “sense of magic” zeroes out.

Take HS256; the whole journey is four steps:

  1. Serialize the header and payload each in compact form (key order and spaces all count), UTF-8 encoded into bytes;
  2. base64url-encode the two byte strings separately and join them with a dot into the signing input. Note that no secret participates in this step; anyone can compute it — it is merely “the content to be signed”;
  3. Compute HMAC-SHA256 over the entire signing input, yielding a 32-byte digest;
  4. base64url the digest once more — a fixed 43 characters (256Ă·8=32256 \div 8 = 32 bytes, 32×4/3≈42.6732 \times 4/3 \approx 42.67 rounded up) — and append it after the second segment.
header {"alg":"HS256","typ":"JWT"} -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload {"sub":"42","exp":1735689600} -> eyJzdWIiOiI0MiIsImV4cCI6MTczNTY4OTYwMH0
signing input = header + "." + payload (no secret involved; anyone can construct it)
signature = base64url(HMAC-SHA256(secret, signing input)) -> 43 characters

Two details deserve a stop.

First: change one character and the whole signature changes.

Change "sub":"42" in the payload to "sub":"43" and re-encode base64url: the segment’s length is unchanged and the eye can barely tell. But the verifier recomputes the HMAC over the tampered bytes, and it cannot match the third segment.

That is the precise meaning of “the signature binds byte-level content”: it tolerates not a single bit of change, and by the same token has no tolerance for “same semantics, different bytes” (reordered keys, one extra space). The same set of claims, written differently, is a different token.

Second: the third segment’s length leaks the algorithm.

The HS384 and HS512 digests are 48 and 64 bytes, which become 64 and 86 base64url characters. Handed a three-segment token, one glance at the third segment’s length back-derives the signature algorithm family.

It is also the fastest home-grown way to chase down inconsistencies like “the docs say HS256 but something else is actually signed”.

The revocation problem

Traditional session authentication keeps state on the server: one row per logged-in user, deleted on logout; kicking someone out is deleting a row.

JWT runs the other way: the server stores nothing, the token carries its own exp, and verification relies only on the signature and the expiry time.

The reward is enormous freedom in horizontal scaling: any machine holding the public key (or shared secret) can verify independently, with no shared session store.

The price shows itself at the moment of “invalidating early”.

Before exp, the token vouches for itself; for the server to actively revoke a token that hasn’t expired yet, it must introduce state — exactly the thing the stateless design set out to eliminate.

Leak an access token with a 7-day validity and you have no “void immediately” button.

This problem has no free solution, only a set of trade-offs:

  • Short validity + refresh token: squeeze the access token down to 5 to 15 minutes; the real long-lived credential is the refresh token, stored server-side and revocable, so a leaked access token does damage for a quarter of an hour at most. This is the current mainstream: leaving “the stateless convenience” to the high-frequency small tokens and reclaiming “stateful control” for the low-frequency large one. This layer can harden further with refresh token rotation: each time a refresh token is exchanged for a new access token, the old refresh token is voided on the spot and a new one issued. The moment a voided refresh token is presented again, theft is established (a legitimate client cannot hold a voided credential), and the user’s entire token family is revoked immediately. Replay detection turns “credentials stolen” from a silent loss into an observable security event, at the price of refresh-token verification being fully stateful;
  • Blacklist: write revoked jtis (token unique IDs) into Redis and look them up once more per verification. Effective, but every check costs one more network round trip, and the blacklist must cover every exp. The stateless advantage is halved; you have implemented a session with a JWT;
  • Version numbers: put a field like “password-changed-at / global revocation version” into the user table, carry a copy in the token, and compare at verification. One revocation kills all old tokens — coarse-grained but the simplest to build.

All three share one insight: revocation is inherently a stateful operation.

Engineering’s mature answer is not to avoid state but to put it in the right place: the low-frequency credential layer, not the high-frequency request layer.

Clock skew and JWKS

Choose an asymmetric algorithm and verification stops being “a matter inside one process” and becomes cross-network collaboration, adding a whole set of infrastructure problems concentrated on two things: time and public-key distribution.

Time.

exp and nbf are both absolute timestamps, yet verification happens on “someone else’s clock”.

In production clusters, machine clocks drifting tens of seconds is not rare, and user devices are worse. Manually adjusted phone time, mangled time zones, clocks jumping back after a battery dies — any of these makes a “just-issued” token look “not yet valid” or “already expired” to the verifier.

The convention is to add leeway (a grace window, usually on the order of tens of seconds) to the time assertions: loosen the verdict from strict now < exp to now < exp + leeway and now > nbf - leeway, demoting clock error from “randomly rejecting legitimate requests” to “slightly generous at the boundary”.

Leeway is an explicit trade of safety margin for availability. Write it into configuration where people can see it, not buried as a magic number nobody knows exists.

Public-key distribution.

Where does an RS256 verifier get the public key? The answer is the JWKS (JSON Web Key Set) endpoint.

The auth center publishes an address like /.well-known/jwks.json holding a set of public keys, each with a kid (key ID). Issuing writes the kid used into the header, and the verifier fetches the key by number.

The reason this machinery exists is key rotation. When the private key leaks or a routine change comes due, the auth center adds the new public key to JWKS and switches signing to the new private key, keeping the old public key until the last old token expires. No verifier needs notifying; rotation is just a data update.

The price is that verifiers must implement the caching strategy correctly: cache the JWKS locally after fetching, refresh from source when an unknown kid appears, and refresh at least once before rejecting for an “unknown kid”. Skip the refresh and reject outright, and the instant of rotation is a full-scale 401 incident.

Add one more attack to the failure list while we’re here: kid injection.

In some implementations the kid gets used to query databases or build file paths; an attacker crafts a header like {"kid": "x' UNION SELECT 'the-key-I-control"} and may get the verifier to check signatures with an attacker-supplied key.

The signature mechanism falls entirely, and this stroke never touched cryptography. It cut the implementation habit of “treating a lookup index as trusted input”.

Any code that concatenates kid into SQL, paths, or commands is a brand-new entrance for injection.

The failure checklist

JWT’s security model is clean; the crashes happen almost entirely at the implementation layer.

The following are ordered by frequency, each worth checking against your own code.

The none algorithm attack

The alg field in the header declares “what algorithm I signed with”, but it sits in the unencrypted header, and attackers can rewrite it at will.

The classic attack (which swept through a batch of libraries around 2015): change alg to none, delete the signature segment, and declare this an “unsigned token”. Libraries with lax implementations trust the payload directly, skipping the ticket check entirely.

The defense principle is one line: the verifier never trusts the token’s self-declared alg; the code hard-codes an allowlist of permitted algorithms, and rejects any value outside it outright.

Algorithm confusion

A sneakier variant: the server issues with RS256, but the verification code “dynamically picks the algorithm from the token’s alg”.

The attacker flips alg to HS256 and signs with the RSA public key, which was public anyway, as the HMAC secret. The verifier fetches the public key, computes the HMAC, and it matches.

The defense shares roots with the previous item: hard-code the algorithm allowlist; in RS256 scenarios, the HMAC path must never exist.

Weak secrets

HS256’s security rests entirely on the secret’s entropy.

Secrets like secret, your-256-bit-secret, or company name plus year fall to a dictionary in seconds. With the secret, an attacker forges tokens for any identity offline.

Defense: HS256 secrets at least 256 random bits (openssl rand -base64 32), rotated regularly. Secrets never go into frontend code or public repositories.

Blind trust in expiry, and storage location

Two classes of common error: verification code that “can’t be bothered” to check exp, nbf, aud, iss (the signature checked out, so let it through — and a token expired ten years ago still works).

And the reverse: the frontend reads role/permission fields from the payload and renders the control UI directly. The UI can be spoofed, the payload can be forged arbitrarily, and every authorization decision must be redone server-side from a verified token.

Storage location is another fork: localStorage is convenient but any XSS steals the token; HttpOnly + Secure + SameSite cookies keep JS away but require CSRF handling.

No perfect option — only the choice that matches your threat model.

Library defaults

The last item is the most hidden: a signing library’s default behavior is not security behavior.

The same class of accident recurs. Verification functions make “algorithm” an optional parameter and read it from the token’s header when absent (a breeding ground for none attacks and algorithm confusion). decode and verify are two functions, the former doing only base64 decoding and no verification at all, so making an authorization decision on decode’s output equals not verifying. Libraries with expiry checking off by default or behind an explicit flag genuinely exist.

What these defaults share is being over-generous to the integrator: the code runs, the tests pass, everything is normal with legitimate tokens, and the only thing left wide open is a crafted malicious token.

The defense is not memorizing the library’s docs but establishing one code-review rule: every token verification on an auth path must pass the algorithm allowlist explicitly and enable every time and audience assertion explicitly. Security parameters are always hard-coded, never left to defaults.

The complete list of these failure points — together with the other categories of injection, session management, and access control — doesn’t need memorizing.

The Security Reference collects a set of quick-reference topics including the OWASP Top 10 (2021); follow the map during code review.

JWT-related checks boil down to a single throughline: the verifier makes no assumptions about any segment of the token. Algorithm hard-coded, secret entropy sufficient, claims checked item by item, authorization never trusting the payload.

The essence of the login state

Gather the threads and JWT’s full picture is a “self-certifying credential”: not a session replacement but a trade with prices clearly marked — give up revocation’s immediacy, get back scaling’s statelessness.

One more common architectural question remains: which layer does the verification?

A gateway verifying uniformly and passing user identity through to internal services is the minimal central-control route, at the price that the “trust the headers” boundary inside the network must be backstopped by network isolation.

Each service verifying for itself is zero-trust in full, at the price of every service absorbing the whole JWKS, leeway, and algorithm-allowlist apparatus.

Most teams’ real answer is a compromise: the gateway does one full verification to block external traffic, and internal services do a light verification against direct intranet access.

Understand this trade’s debits and credits first, then decide which layer holds the token, how short the expiry, and which key system.

A token answers “who is on this connection”; upgrade that connection into a multiplayer arena and the question immediately becomes “how do all clients see the same world”. By the way, the JWTs of the previous chapter are regulars in WebSocket authentication — though they must never go straight into a URL. The next chapter takes turn-based and Draw & Guess style games as its backdrop and lays out the engineering answers to sync and reconnects.

The long-connection arena: sync, protocol, and reconnects

The first two of those questions define the two great schools of networked games: state sync and lockstep; the answer to the last decides whether your room ends up on players’ blacklists as a dead room.

Hands-on versions: API Toolkit, API Mock Generator, Draw & Guess canvas.

Why WebSocket

The first step in going multiplayer is letting the server “speak first”.

HTTP is a request-response model: if the client doesn’t ask, the server doesn’t answer.

To get opponent moves into a browser game in real time, there were only three old roads. Polling on a timer (latency equals the interval, and empty requests arrive in a tsunami), long polling (hold the request until there is data, then reissue after each response — a connection-management nightmare), and SSE (the server pushes one way, easy, but upstream still has to go through HTTP).

WebSocket straightens that road: the browser first sends an HTTP request with an Upgrade: websocket header to complete the handshake, after which that TCP connection is upgraded into a full-duplex channel where both sides fire frames at will, with no request-header overhead and no wasted reconnects.

The cost deserves equal clarity: WebSocket needs the server to hold every connection in memory (HTTP’s stateless advantage is gone), it needs proxies and load balancers to support long-lived connections (sticky sessions), and production must use wss:// encryption.

Put the four real-time channels side by side and the differences are obvious:

ChannelDirectionLatencyOverhead profile
Short pollingClient pullsFrom one interval upEmpty-request tsunami, headers dwarf the payload
Long pollingServer pushes (fake)LowerRe-handshake after each response, connection-management nightmare
SSEServer pushes one wayLowLightweight, but upstream still rides HTTP
WebSocketFull duplexLowestOne handshake, then frame-level overhead (2–14 bytes)

There is a plain dividing line for whether to use WebSocket: who initiates the message.

Pure client-side pulling, HTTP is enough. Bidirectional, low-latency, high-frequency: WebSocket enters.

A game is exactly the extreme case of the latter. This is a different worldview from writing REST endpoints: endpoints talk about “resources”, connections talk about “event streams”.

What a connection looks like is something you can touch yourself.

API Toolkit is a small tool that treats the protocol like a REPL: enter any ws:// or wss:// address to connect, send messages, and the log pane records open, every incoming message, error, and close in real time.

Build up the feel of connecting, sending and disconnecting against a public echo service first; reading your own server’s logs afterwards goes far more smoothly.

After the handshake

A completed handshake is not a quiet life; the fate of a long connection is usually decided in three sandwiched layers.

The frame layer.

Every WebSocket message travels as a “frame”: text frames, binary frames, ping/pong control frames, close frames.

The browser API flattens all of it. send() with text sends a text frame, and onmessage hands you the decoded message.

But two things leak up to the application layer. One is masking: the protocol forces every client-to-server frame to carry a random mask (a historical leftover against proxy cache poisoning), while server-to-client frames are unmasked, so “client to server” and “server to client” traffic costs are inherently asymmetric. The other is message boundaries: TCP is a byte stream, and WebSocket preserves message boundaries for you, but one large message can arrive split across several frames — with a binary protocol, carry your own length prefix or separator discipline in the application layer.

The heartbeat layer.

RFC 6455 ships ping/pong control frames, but the browser’s JavaScript API doesn’t expose them. send() can only send data frames.

So an application-level heartbeat became the de facto standard: the client sends a minimal application message every 20 to 30 seconds (say {"t":"ping"}), and the server, hearing nothing within the agreed window, declares the connection dead and reclaims it.

This is not optional: NAT gateways, firewalls and reverse proxies all silently cut connections that “look idle”, with timeouts from 30 seconds to 5 minutes. And when they cut, neither side receives a close event.

Without a heartbeat, a connection dies in silence.

The proxy layer.

In production there is almost certainly a reverse proxy in front of WebSocket, and most proxies configure timeouts around HTTP’s request-response rhythm.

Nginx’s proxy_read_timeout defaults to 60 seconds, meaning a connection that sends nothing for 60 seconds gets cut by the proxy. The heartbeat interval must sit below that line.

The other thing to confirm before shipping is Upgrade header passthrough: by default a proxy treats an HTTP upgrade request as an ordinary one, and if either proxy_set_header Upgrade $http_upgrade or Connection "upgrade" is missing, the handshake fails with an inexplicable non-101 status code.

The symptom always presents as “fine locally, dies the moment it’s online”.

The two schools

Every networked game must answer the same question: each client simulates the world itself, so how do you guarantee they all see the same one?

The two schools give answers pointing in opposite directions.

State sync: the server authoritatively runs the game logic and broadcasts game state (or state deltas) to everyone; clients only render.

Chess, cards, board games and most turn-based games pick it. One state broadcast per turn, a data volume small enough to ignore, and the cheat surface squeezed to the minimum (the client carries no game logic at all, so it can’t compute a “guaranteed win” add-on).

Lockstep (frame sync): the server broadcasts only player input commands, and each client, once it has all inputs, deterministically simulates the same world on its own.

The traditional option for RTS and FPS. Broadcasting the state of the tens of thousands of units in a StarCraft match frame by frame would blow the bandwidth; a command stream is a few bytes per frame.

Its demands on determinism are absurdly strict: floating-point arithmetic, RNG seeds, iteration order, even the floating-point behavior of different CPUs all have to match bit for bit, or the two clients’ worlds quietly fork and minutes later each is playing a different game.

DimensionState syncLockstep
UploadState/deltas, largerCommands only, tiny
Client roleRenderingFull simulation + rendering
DeterminismNot requiredBit-for-bit
Anti-cheatNaturally strong (logic is server-side)Weak (all logic on the client)
ReconnectPull a current snapshotReplay the entire command history
FitsTurn-based, cards, Draw & GuessRTS, fighting, MOBA

The core of the trade is bandwidth for control: lockstep buys bandwidth by sending “commands only”, paying in determinism and reconnects. State sync buys safety and simplicity with “the server decides”, paying in bandwidth and server CPU.

For turn-based and party mini-games, bandwidth was never the bottleneck, and there is no reason to give up state sync’s peace of mind.

The ledger of Draw & Guess

The interesting part is that Draw & Guess style games belong to neither pure school; they are a hybrid of the two.

Which is also what makes them such an excellent networking exercise.

The site’s Draw & Guess canvas is the single-player form: a local canvas with a word bank, a randomly drawn word to paint, and the ability to reveal the answer and move to the next at any time. Timed mode is a 60-second countdown, scored by the time left when you finish.

All of its data happens to split into two categories, mapping exactly onto the two streams of the multiplayer version:

  1. Strokes are a command stream (lockstep style). A stroke is essentially a command sequence of begin(x, y), move(x, y) × N, end(). The multiplayer version never needs to ship canvas snapshots around; broadcast the commands, replay them locally on every client, and the same line appears. Bandwidth optimization is command-stream style too: batch the 60Hz mouse samples per frame, send delta coordinates, and one stroke compresses to a few hundred bytes;
  2. The round is a state stream (state sync style). The current word, the countdown, who is drawing, each player’s score, the list of players who guessed right. The server manages these authoritatively and broadcasts them by event. Guesses go through server adjudication (the guesser can’t declare “I got it” themselves), and the countdown is emitted by the server (clients can’t count their own seconds, or they drift sooner or later).

This hybrid structure is a template for every mode that mixes process and adjudication: process (strokes, chat, emotes) rides the command stream for real-time feel, adjudication (score, round, win/lose) rides the state stream for consistency.

Which stream carries what has to be written into the protocol at design time, not patched in halfway through implementation.

Push the ledger one step further and you run into the division of labor in broadcast semantics.

Messages in a room have three delivery modes, each with a clear boundary: broadcast (to everyone in the room) for strokes, chat, public state changes. Unicast (the server sends to one connection only) for anything that must stay secret. The classic case is “the word to draw”, which can only go to the drawer — broadcast it and the guessers open devtools, read one WebSocket frame, and cheat outright. Ack (the server’s confirmation to the sender) for ACKs and error notices.

Unicast’s secrecy carries an often-ignored corollary: confidentiality is guaranteed by the server, not the frontend.

The word sits in plaintext in the drawer’s client memory; all the protocol can guarantee is that “no byte of that word ever appears on a non-drawer connection”.

Three protocol habits

Connecting is only the beginning; how messages are organized decides how long the multiplayer system lives.

Habit one: every message carries an envelope.

Sending raw game data is the fastest trap for newcomers; the next day you want “packet loss stats” and every client needs surgery.

Fix the envelope fields from day one:

{ "t": "stroke", "seq": 1042, "ts": 1699000000000, "room": "r7x2", "d": { "x": 33, "y": 87 } }

t is the message type, seq the sender’s monotonically increasing sequence number, ts the timestamp, d the payload.

The sequence number lets you reconcile after a reconnect (“what did I miss while I was disconnected”), the timestamp makes interpolation and timeout decisions possible.

Habit two: separate reliable-ordered from “latest is truth”.

WebSocket runs on TCP, so all messages are naturally reliable and ordered.

That is a blessing for guesses and chat, but possibly overkill for strokes: after half a second of network jitter, a string of late old stroke points arrives in a batch and the picture suddenly “twitches as it catches up on history”.

The right posture for position and stroke messages is carry a sequence number and only accept messages newer than the last applied one, dropping late old deltas outright.

This is the “latest state wins” semantic: reliable TCP carrying unreliable business semantics.

Habit three: heartbeat and frame batching.

Proxies and NAT silently cut long connections that “look idle”, so the client sends a ping frame (or a minimal application-level heartbeat) every 20 to 30 seconds to keep the connection alive, doubling as a disconnect detector.

On the sending side, batch frames: merge the several move commands accumulated within one frame into a single message before sending, which cuts traffic and gives the server room to breathe.

Sixty messages per second per client can bring down a room; batched, it is a few per second.

While the protocol isn’t settled and the server isn’t ready, client development needn’t wait: API Mock Generator generates random JSON data in batches from a declared schema (key-value pairs like {"id": "integer", "name": "string", "active": "boolean", "tags": ["string"]}).

Mock the envelope’s payload first and the frontend’s state machine and rendering logic can test themselves ahead of schedule.

Connection identity and slow-consumer backpressure

Two topics engineering can’t dodge and tutorials rarely cover, taken together.

Authentication.

An HTTP request can carry an Authorization header, but a browser WebSocket handshake doesn’t allow custom request headers.

How to carry credentials needs designing on purpose.

Four common roads: cookies, which the handshake request sends automatically (borrowing the HTTP channel, the least work when deployed same-site, but limited across origins and on mobile); one-time tickets, exchanging an HTTP login endpoint for a ticket valid for 30 seconds and appending it to the handshake URL’s query string, with the server binding the connection to the user after validating it — query strings land in access logs, so tickets must be short-lived and single-use, and a long-lived JWT must never go straight into a URL; first-message auth, where the first message after the handshake must carry credentials or the connection is dropped on timeout; and smuggling the token in the WebSocket subprotocol field (Sec-WebSocket-Protocol), which works but stretches the semantics and isn’t recommended.

The bottom line for all four: authentication happens during the handshake or in the first message, never “connect first and claim who you are later”. That hands the right of declaring identity to the client.

Reconnects simplify from there: reconnecting means running through handshake auth again, and the ticket mechanism is naturally idempotent.

Backpressure.

The invisible killer of broadcast-style rooms is the slow consumer: one player’s network stalls, the server keeps pouring stroke data into their TCP send buffer at full rate, and once the buffer fills the data piles up in the server’s in-memory queue.

The connection isn’t dead (TCP doesn’t fail on its own), the player hasn’t left the room, and the server is broadcasting every stroke to a disabled connection for nothing.

Both the browser and Node give you the detector: ws.bufferedAmount (bytes not yet sent) climbing steadily is the slow consumer’s fingerprint.

The response depends on the game’s tempo: turn-based can simply wait. Real-time needs “degraded delivery” — skip that client’s old deltas, send only the latest snapshot, and if that fails, drop and reconnect (there’s a snapshot to reconcile against anyway).

Alongside backpressure sits the fan-out cost of broadcast: one message to a 50-player room is 50 send() calls.

Batching strokes (merging several points of a frame into one message) relieves the server and the slow consumer at once.

That is the second reason for batching in “habit three”.

Neither topic appears in a minimal demo, yet half the post-mortems of production multiplayer incidents involve them.

Reconnection and room lifecycle

On mobile networks a dropped connection is the norm, not an exception, and the quality of the reconnect design goes straight into player word of mouth.

The connection layer: beyond readyState’s four states (CONNECTING / OPEN / CLOSING / CLOSED), the application keeps its own reconnect state machine.

Reconnects use exponential backoff with jitter (1s, 2s, 4s
 capped at 30s, plus a random amount each time). Reconnects without backoff merge into a storm the instant the server restarts, knocking the just-recovered service down again.

The business layer: a successful reconnect isn’t a restored game; two steps remain.

First reconcile: send the last processed seq from before the drop and ask the server to resend the gap.

Then take a snapshot: in the state sync school, pull a complete snapshot of the current room and overwrite the local one. This is state sync’s greatest engineering kindness over lockstep, which at this point must replay every command from the opening — an order of magnitude more expensive.

The player-visible goal follows from it: in a state sync game, rejoining within 30 seconds of a drop should put you seamlessly back in place.

The room layer: the room itself is a state machine that needs designing with care.

waiting → playing → scoring → back to waiting, plus a few boundaries that are easy to miss: empty-room reclamation (count down and close after the last player leaves, against leaks), host migration (appoint a new host by rule when the host drops), seat reservation (hold a dropped player’s seat for a grace period instead of kicking immediately, removing them only when it expires), re-entry idempotency (the reconnect request carries the old identity credential, so the server recognizes “the old player returning” rather than “a new player joining”, and the game state isn’t reset).

Every boundary left out of the state machine becomes, in production, a dead game stuck at “60 seconds forever” or a ghost room “always one player short”.

The reconnect design is one layer short of complete: translating it into something the player can see.

Connection state and application state are two independent state machines, and the UI must subscribe to both.

The “reconnecting” banner must appear before the player’s confusion does (show it on the first backoff), the seat grace period must be visualized in the room (“someone dropped, removed automatically in 30 seconds”), and a failed recovery must offer a clear exit (back to the lobby rather than stuck on a black screen).

The industrial standard for disconnect experience fits in one sentence: the player should always know what is happening and whether they can still get back.

Every state machine in the network layer ultimately serves that sentence.

A server skeleton starting from echo

Sinking the minimal skeleton of all the above into Node takes a few dozen lines. A broadcast echo room in embryo:

import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map(); // roomId -> Set<ws>
wss.on("connection", (ws) => {
ws.on("message", (raw) => {
const msg = JSON.parse(raw);
if (msg.t === "join") {
ws.room = msg.room;
if (!rooms.has(ws.room)) rooms.set(ws.room, new Set());
rooms.get(ws.room).add(ws);
return;
}
// server adjudication: only the current drawer's strokes are broadcast
if (msg.t === "stroke" && ws.isDrawer) {
for (const peer of rooms.get(ws.room)) {
if (peer !== ws && peer.readyState === 1) peer.send(raw);
}
}
});
ws.on("close", () => {
const room = rooms.get(ws.room);
if (room) { room.delete(ws); if (room.size === 0) rooms.delete(ws.room); }
});
});

Heartbeats, backoff reconnects, snapshot reconciliation and the room state machine all layer onto this skeleton one at a time.

A single process of this structure carries a mid-sized room comfortably. When you need multiple instances, add sticky sessions so the same connection lands on the same process, and string cross-room broadcasts together with Redis pub/sub; the expansion path is clear and incremental.


Thanks for reading! Follow me if you'd like~

JWT and WebSockets

Thu Jun 04 2026
6220 words · 33 minutes