Skip to content

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.

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)"]
Token bucket: tokens refill over time; each message spends one

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 maxPayload so oversized frames are rejected at the protocol level — use it, then double-check in your handler.
  • Parse in a try/catch. A JSON.parse on attacker input will throw eventually. Catch it; never let a malformed frame crash the connection or the process.
  • Validate the shape. Confirm type is 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.

JavaScript

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.

What property makes a token bucket well suited to chat-style traffic?
Why should rate limiting be per connection rather than global?
What is the safe way to handle an incoming JSON message frame?