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.

Three client behaviours and the correct response to each A normal typist stays under the per-connection budget and is never limited, a buggy client has excess messages dropped with an error frame while the socket survives, and an abusive account opening many sockets is disconnected with close code 1008. Same protocol, three responses typist: 8 msg/s under the 20/s budget no limit ever applied no error frames response: nothing buggy: 4000 msg/s excess dropped silently one error frame per second socket stays open response: drop + inform abuser: 50 sockets account budget exceeded connection cap hit close code 1008 response: disconnect closing a socket for one fast message turns a client bug into a reconnect storm

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

typescript
// 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:

typescript
// 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));
}
Sampling the account budget bounds both cost and overshoot Checking the shared account bucket on every message costs one store call per message, while checking every twenty-fifth message cuts store traffic by ninety-six percent and bounds the overshoot to twenty-four messages per connection. Every message, or every twenty-fifth? check every message 10,000 msg/s = 10,000 store calls/s overshoot: zero store becomes the bottleneck latency added per message check every 25th 10,000 msg/s = 400 store calls/s overshoot: at most 24 per socket charge 25 units per check per-connection bucket covers the gap pick the interval from the overshoot you can tolerate per connection Outbound back-pressure on a slow consumer While the client drains messages the buffered amount stays near zero and pushes proceed; once the client stalls the buffer grows past the threshold and non-critical updates are dropped instead of accumulating until the process runs out of memory. The limit that protects the server, not the client client draining buffered: near zero push every update client stalls buffered: 1 MB and rising drop non-critical updates no threshold buffer grows unbounded server runs out of memory inbound limits protect the service; outbound thresholds protect the process holding the socket

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 maxPayload on 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 bufferedAmount is a memory exhaustion bug that looks like a rate limiting problem. Bound it explicitly.

Verification and testing

javascript
// 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.