WebSocket Message Rate Limiting
A WebSocket client performs one HTTP handshake and then sends as much as it likes, so any limiter that counts requests stops counting the moment the upgrade succeeds. The unit that matters afterwards is the message, and the identity that matters is both the connection and the account behind it β the two-level pattern introduced in protocol-specific throttling and made concrete here.
The problem in concrete numbers
A collaborative editor sends a message per keystroke batch: roughly 8 per second while typing, 30 per second during a paste-and-format burst. A buggy client with a re-render loop sends 4,000 per second. A malicious one opens 50 sockets and sends 200 each. All three look identical to an HTTP rate limiter, which saw exactly one upgrade request per socket and nothing since.
The three cases need three different answers: the typist must never notice a limit, the buggy client should have its excess dropped while the session survives, and the abuser should lose the accountβs connections entirely. A single threshold cannot express that; a per-connection budget plus an account roll-up plus a connection cap can.
Decision matrix: what to count
| Unit counted | Catches | Misses | Cost to enforce |
|---|---|---|---|
| Messages per connection | Runaway loops on one socket | Many-socket abuse | Free: in-process counter |
| Messages per account | Many-socket abuse | Nothing, if sampled correctly | One store call per sample |
| Bytes per connection | Large-payload floods | High-rate tiny messages | Free |
| Command cost (weighted) | Expensive operations sent as messages | β | Cost table maintenance |
| Concurrent connections per account | Budget multiplication | In-connection floods | One counter at handshake |
| Subscriptions per connection | Fan-out amplification | Direct message floods | Registry lookup |
Most production systems need three of these at once: messages per connection (cheap, immediate), messages per account (sampled), and connections per account (checked at handshake). Byte limits matter when payloads are user-supplied documents rather than small deltas.
Step-by-step implementation
// ws-limits.ts β connection budget, sampled account budget, connection cap.
import { WebSocketServer, WebSocket } from "ws";
const PER_CONN_MSG_PER_SEC = 20;
const ACCOUNT_SAMPLE_EVERY = 25; // 1 store call per 25 messages
const MAX_CONNS_PER_ACCOUNT = 8;
const MAX_PAYLOAD_BYTES = 64 * 1024;
const conns = new Map<string, number>(); // accountId -> open sockets
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES });
// Reject at the handshake: no socket is created, so the cost is one HTTP response.
export function handleUpgrade(req, socket, head, verifyToken, consume) {
const account = verifyToken(req.headers["sec-websocket-protocol"]);
if (!account) { socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); return socket.destroy(); }
if ((conns.get(account) ?? 0) >= MAX_CONNS_PER_ACCOUNT) {
socket.write("HTTP/1.1 429 Too Many Requests\r\nRetry-After: 5\r\n\r\n");
return socket.destroy();
}
wss.handleUpgrade(req, socket, head, (ws) => attach(ws, account, consume));
}
function attach(ws: WebSocket, account: string, consume) {
conns.set(account, (conns.get(account) ?? 0) + 1);
ws.on("close", () => conns.set(account, Math.max(0, (conns.get(account) ?? 1) - 1)));
let tokens = PER_CONN_MSG_PER_SEC;
let last = Date.now();
let sinceSample = 0;
let lastErrorSent = 0;
ws.on("message", async (raw) => {
const now = Date.now();
tokens = Math.min(PER_CONN_MSG_PER_SEC,
tokens + ((now - last) / 1000) * PER_CONN_MSG_PER_SEC);
last = now;
if (tokens < 1) {
// Drop the message. Rate-limit the error itself so it cannot amplify.
if (now - lastErrorSent > 1000) {
lastErrorSent = now;
ws.send(JSON.stringify({ type: "error", code: "rate_limited", retryAfterMs: 1000 }));
}
return; // socket survives β this is usually a client bug
}
tokens -= 1;
// Account-wide check, sampled: catches many-socket abuse without per-message I/O.
if (++sinceSample >= ACCOUNT_SAMPLE_EVERY) {
sinceSample = 0;
const v = await consume(`ws:${account}`, ACCOUNT_SAMPLE_EVERY);
if (!v.allowed) {
ws.send(JSON.stringify({ type: "error", code: "account_rate_limited" }));
return ws.close(1008, "account message budget exceeded");
}
}
handleMessage(ws, account, raw);
});
}Outbound back-pressure is the other half, and it is the half that takes servers down. If you push faster than a client reads, bufferedAmount grows until the process runs out of memory:
// Never push into a socket that is not draining.
const MAX_BUFFERED = 1 * 1024 * 1024; // 1 MB of unsent data
function push(ws: WebSocket, payload: object) {
if (ws.readyState !== WebSocket.OPEN) return;
if (ws.bufferedAmount > MAX_BUFFERED) {
// The client cannot keep up: drop non-critical updates, or close the slow consumer.
metrics.increment("ws.dropped_slow_consumer");
return;
}
ws.send(JSON.stringify(payload));
}Gotchas and edge cases
- Closing on the first violation causes reconnect storms. A client that is disconnected reconnects immediately, re-authenticates, and resumes the same loop β now with handshake cost added. Drop messages first; close only for account-level abuse.
- Error frames can amplify. Sending one error per rejected message doubles the traffic during a flood. Cap error frames to roughly one per second per socket.
- Per-connection state resets on reconnect. A client cycling sockets gets a fresh budget each time; the connection cap and the account bucket are what stop it, not the per-connection bucket.
- Compression changes the cost of a message. With permessage-deflate a small compressed frame can expand to megabytes. Set
maxPayloadon the decompressed size and reject oversized frames at the protocol layer. - Ping/pong and control frames. Do not charge protocol control frames against the userβs budget, but do bound them β a client sending 1,000 pings per second is still a flood.
- Sampling with a fixed offset lets a client stay just under. Charge the full sample interval at each check (25 units per check, as above) rather than one, so the accounting is conservative rather than optimistic.
- Slow consumers are a server-side leak. Unbounded
bufferedAmountis a memory exhaustion bug that looks like a rate limiting problem. Bound it explicitly.
Verification and testing
// A load client that proves each of the three behaviours in turn.
import WebSocket from "ws";
async function burst(count, perSocket = 1) {
const sockets = Array.from({ length: perSocket }, () =>
new WebSocket("wss://api.example.com/stream", { headers: { Authorization: TOKEN } }));
await Promise.all(sockets.map((s) => new Promise((r) => s.on("open", r))));
let errors = 0, closes = 0;
sockets.forEach((s) => {
s.on("message", (m) => { if (JSON.parse(m).type === "error") errors += 1; });
s.on("close", (code) => { if (code === 1008) closes += 1; });
});
for (let i = 0; i < count; i += 1) sockets.forEach((s) => s.readyState === 1 && s.send("{}"));
await new Promise((r) => setTimeout(r, 2000));
return { errors, closes };
}
// 1. Normal rate: no errors, no closes.
console.log(await burst(30, 1)); // expect { errors: 0, closes: 0 }
// 2. Runaway single socket: errors, but the socket survives.
console.log(await burst(5000, 1)); // expect errors > 0, closes: 0
// 3. Many sockets over the account budget: policy close.
console.log(await burst(2000, 12)); // expect closes > 0 (or handshake 429s)Assert on the shape of the result, not on exact counts: the per-connection bucket refills continuously, so the number of dropped messages varies with timing while the qualitative outcome does not.
Frequently Asked Questions
Should I close the socket when a client exceeds its message rate?
Not for a per-connection breach. Dropping the excess and sending a throttled error frame keeps a buggy client working at reduced throughput instead of trapping it in a reconnect loop, which costs your server more than the messages did. Reserve close code 1008 for account-level abuse.
How do I stop a client multiplying its budget by opening more sockets?
Cap concurrent connections per account at the handshake, and roll per-connection message counts up to an account-wide bucket. The cap alone is not enough if the limit is generous, and the roll-up alone is not enough if a client can open thousands of cheap sockets.
Where should the per-connection counter live?
In the process holding the socket. A connection is pinned to one server for its lifetime, so its counter needs no coordination and costs nothing. Only the account-wide roll-up needs shared state, and that can be sampled rather than checked per message.
Do I need to limit outbound messages too?
Yes, but as back-pressure rather than as a policy limit. Watch the socket's buffered amount and drop or coalesce non-critical updates for consumers that cannot keep up; an unbounded outbound buffer exhausts server memory long before any inbound limit triggers.
Related
- Protocol-Specific Throttling β the parent guide covering cost functions for non-REST protocols.
- GraphQL Query Cost Rate Limiting β pricing subscriptions that ride on these sockets.
- gRPC Interceptor Rate Limiting β the same mid-stream problem in a different transport.
- Redis Counter Architecture β the shared store behind the sampled account bucket.