Rate Limiting WebSocket Messages Per Client #
One client with a runaway setInterval is enough. It fires cursor updates every millisecond, your server parses 1,000 JSON messages a second from a single socket, fan-out multiplies each one across the room, and the whole node’s event loop lag goes from 2 ms to 400 ms. Every other user in that room now feels the lag. HTTP rate limiting does not help you here: there is one request — the upgrade — and everything after it is frames on an open connection that your gateway is not inspecting.
This page implements a per-connection token bucket that costs no timers, applies to both directions, and closes abusive connections with a code the client can actually respond to.
Root cause #
WebSocket has no built-in flow control at the application level. RFC 6455 gives you framing, not fairness. Once the upgrade completes, a client may send frames as fast as its TCP connection allows, and your server will parse every one of them because that is what a message handler does.
The naive fix — a setInterval per connection that refills a counter — is worse than the problem. At 50,000 sockets it creates 50,000 timers, and Node’s timer implementation walks them on every tick; the CPU cost of the rate limiter exceeds the cost of the traffic it was meant to bound. The fix is to make refills lazy: store when the bucket was last touched, and compute how many tokens have accrued at the moment you need one. No timer, no background work, and memory of exactly two numbers per connection.
The second design decision is what to do when a client exceeds the budget. Silently dropping messages makes debugging miserable for legitimate clients that briefly burst. Closing immediately turns a transient spike into a reconnect storm. The workable middle ground is to drop with a counter, warn the client once via a control message, and only close when the violation is sustained over multiple windows — at which point 1008 Policy Violation tells the client this was deliberate.
Resolution #
The bucket is fifteen lines. Everything else is policy: which messages are exempt, how violations escalate, and what the client is told.
import { WebSocket } from 'ws';
const TOKENS_PER_SECOND = 60; // sustained budget per connection
const BUCKET_CAPACITY = 120; // burst allowance (2 s at the sustained rate)
const VIOLATION_WINDOW_MS = 10_000; // window over which violations are counted
const MAX_VIOLATIONS = 3; // sustained abuse before we close
interface Limited {
socket: WebSocket;
tokens: number;
lastRefillMs: number;
violations: number;
violationWindowStart: number;
warned: boolean;
dropped: number;
}
export function createLimited(socket: WebSocket): Limited {
const now = Date.now();
return { socket, tokens: BUCKET_CAPACITY, lastRefillMs: now, violations: 0,
violationWindowStart: now, warned: false, dropped: 0 };
}
/** Lazy refill: no timer, cost is one multiply per message. */
function take(l: Limited, now: number): boolean {
const elapsedSec = (now - l.lastRefillMs) / 1000;
l.lastRefillMs = now;
l.tokens = Math.min(BUCKET_CAPACITY, l.tokens + elapsedSec * TOKENS_PER_SECOND);
if (l.tokens < 1) return false;
l.tokens -= 1;
return true;
}
export function admit(l: Limited, isControl: boolean): boolean {
const now = Date.now();
if (isControl) return true; // pongs and acks are never rate limited
if (take(l, now)) return true;
l.dropped += 1;
// Roll the violation window so an occasional burst never accumulates to a close.
if (now - l.violationWindowStart > VIOLATION_WINDOW_MS) {
l.violationWindowStart = now;
l.violations = 0;
}
l.violations += 1;
// Tell the client once, so a legitimate app can slow down instead of guessing.
if (!l.warned && l.socket.readyState === WebSocket.OPEN) {
l.warned = true;
l.socket.send(JSON.stringify({ t: 'rate_limit', limit: TOKENS_PER_SECOND, burst: BUCKET_CAPACITY }));
}
if (l.violations >= MAX_VIOLATIONS) {
l.socket.close(1008, 'rate limit exceeded'); // 1008 = Policy Violation
}
return false;
}
Wire admit() into the message handler before parsing. Parsing is the expensive part — a 4 KB JSON body costs far more than a bucket check — so a limiter that runs after JSON.parse has already paid the cost it was meant to avoid.
The shape is the point. A client sending 50 messages a second never notices the limiter exists; a client sending 1,000 is clamped to 60 and warned. Nothing in between is disrupted, which is exactly what makes the policy safe to deploy without a long staged rollout.
Verification #
Prove both halves: that a legitimate burst passes, and that sustained abuse is clamped and eventually closed.
# Flood a single connection and watch admitted versus dropped
npx wscat -c ws://localhost:8080/ws -x '{"t":"noop"}' --wait 0 &
curl -s localhost:9090/metrics | grep -E 'ws_messages_dropped_total|ws_rate_limit_closes_total'
# Event-loop lag before and after — the number the limiter exists to protect
node -e "const{monitorEventLoopDelay}=require('perf_hooks');const h=monitorEventLoopDelay();h.enable();setInterval(()=>console.log(Math.round(h.mean/1e6),'ms'),2000)"
The success criteria are behavioural, not numeric: event-loop lag stays flat while a flooding client is connected, the drop counter climbs for that connection only, and other clients in the same room see no change in message latency.
Escalation is deliberately slow. A client has to exhaust its bucket in three separate ten-second windows before the connection is closed, which means a burst, a bad minute on a train, or a single runaway render pass never costs a user their session.
Operational checklist #
FAQ #
Should I rate limit inbound, outbound, or both? #
Both, for different reasons. Inbound limits protect your CPU and your fan-out amplification from a misbehaving client. Outbound limits protect the client and your own memory, and overlap with the queue-depth policy in handling WebSocket bufferedAmount backpressure — the bucket bounds how fast you offer messages, the watermark bounds what happens when the client cannot take them.
Why a token bucket instead of a fixed window counter? #
A fixed window allows double the limit across a boundary: 60 messages at 0.99 s and another 60 at 1.01 s is 120 messages in 20 ms, which is exactly the burst you were trying to prevent. A token bucket has no boundaries to exploit, and it naturally allows a controlled burst — which real clients legitimately produce when a tab regains focus or a user pastes a block of text.
Where should the counter live with multiple nodes? #
Per node, in memory, for abuse protection — it is fast, it needs no coordination, and a client only occupies one socket on one node at a time. Move to a shared store only if you are enforcing a product quota (“100 messages per minute per account”) rather than protecting a process, and expect the round trip to Redis to cost more than the message you are admitting.
What close code should I use? #
1008 Policy Violation is the correct signal for “you broke a rule I enforce”. Avoid 1011 (internal error), which tells the client to retry immediately, and avoid 1013 unless the limit is genuinely temporary capacity pressure rather than a policy. A client that handles close codes properly, as described in auto-reconnection strategies, will back off hard on 1008 instead of hammering you.
Does this work with Socket.IO? #
Yes, with one adjustment: hook the limiter into a middleware on the namespace rather than the raw message event, because Socket.IO has already decoded the packet by the time your event handler runs. That means you pay the decode cost for messages you then drop — less efficient than raw ws, but still enough to protect fan-out amplification and the event loop.
Related #
- WebSocket Backpressure & Flow Control — the surrounding policy, including message classification and drop rules.
- Handling WebSocket bufferedAmount Backpressure — the outbound half, where the client rather than the server is the constraint.
- Fixing Slow Consumer Memory Growth — what an unbounded producer looks like in a heap snapshot.
- Enforcing Origin and CSRF Checks on WebSockets — the other half of abuse protection, applied at the upgrade.
- WebSocket Observability & Monitoring — exporting drop and close counters so limits are reviewable.