Rate Limiting and Validation
An open WebSocket is an open invitation. Once the handshake is done, a client can send as many messages as it likes, as large as it likes, in whatever shape it likes — and the server will dutifully try to handle every one. A buggy client, or a hostile one, can turn that generosity into a denial of service. This lesson is about three cheap defences that keep one connection from ruining the party: rate limiting, size caps, and defensive validation.
Clients can flood you
Section titled “Clients can flood you”There is no built-in throttle on a WebSocket. A while (true) ws.send(...) loop in the browser will fire thousands of frames a second at your server. Each one wakes your event loop, allocates, parses, and runs your handler. Multiply that by the number of connected clients and a single abusive peer can saturate CPU, exhaust memory, or push so much work onto the loop that every other client’s messages are delayed. You need a ceiling, and it has to be per connection so one client’s misbehaviour cannot consume everyone else’s budget.
Token bucket: a rate limit that allows bursts
Section titled “Token bucket: a rate limit that allows bursts”The classic algorithm is the token bucket. Each connection owns a bucket with a maximum capacity of tokens. Tokens refill at a steady rate over time. Every message costs one token: if a token is available, the message is processed and a token is removed; if the bucket is empty, the message is rejected (or the connection is closed for repeated abuse).
The shape of this is exactly what you want for chat-like traffic: a user can send a short burst (spend the whole bucket at once), but their sustained rate is capped by the refill rate. Pick a capacity of, say, 10 and a refill of 5 per second and you get “10 in a burst, then 5/sec after that.”
flowchart TB
R["Refill: +rate tokens/sec<br/>(capped at capacity)"] --> B["Bucket<br/>(0 … capacity tokens)"]
M["Incoming message"] --> Q{"tokens >= 1 ?"}
B --> Q
Q -- "yes: spend 1 token" --> P["Process message"]
Q -- "no: bucket empty" --> D["Reject / throttle<br/>(close on repeat abuse)"] Validate every message defensively
Section titled “Validate every message defensively”Rate limiting bounds how many; validation bounds what. Treat every incoming frame as hostile until proven otherwise:
- Cap the size first. Reject frames larger than a sane limit (a few KB for chat, more for known payloads) before you parse them. Most WebSocket libraries let you set a
maxPayloadso oversized frames are rejected at the protocol level — use it, then double-check in your handler. - Parse in a
try/catch. AJSON.parseon attacker input will throw eventually. Catch it; never let a malformed frame crash the connection or the process. - Validate the shape. Confirm
typeis a known string, required fields exist and have the right types, strings are within length, numbers are in range. Reject anything that does not match a known message shape — do not “best effort” your way through garbage.
A runnable token-bucket and validation demo
Section titled “A runnable token-bucket and validation demo”Because rate limiting is pure logic, the demo below runs in the page — no server needed. It implements a token bucket and a defensive validator, then replays a burst of messages (some valid, some malformed, some oversized) and shows which are accepted, throttled, or rejected. The same consume() and validate() functions drop straight into a real ws.on('message') handler.
Run it and read the verdicts: the malformed and oversized and unknown-type frames are rejected by the validator, and once the burst drains the five-token bucket the trailing frames are throttled regardless of their content. Two independent guards — one on rate, one on shape — and a single connection can no longer flood or confuse the server.