A WebSocket service can be dying while every request counter you own says it is fine.
DDoS testing for WebSocket and real-time services is a controlled test of what a server does when its connections stay open: how many it can hold before memory runs out, whether anything limits the messages that flow after the handshake, how fast it can accept new connections, and what happens when a whole node's worth of clients reconnects at once. It is for teams running chat, live dashboards, trading and betting feeds, multiplayer lobbies, collaborative editors and notification channels, anywhere a client holds a socket for minutes or hours.
The reason it needs its own test is the persistent connection. Request-based testing assumes work arrives as requests, each one bounded, counted and logged. A WebSocket makes exactly one HTTP request, the upgrade, and then stops being HTTP.
What does a WebSocket change about DDoS testing?
It moves the cost from requests to connections. On a request-driven API, load is requests per second and each one ends. On a WebSocket service, load is how many sockets are open, how much state each one holds, and how many messages flow through them, and none of those numbers appear on a request-rate graph.
How a DDoS testing engagement is scoped and authorised on any target is in the complete guide, and the request-side costs of an HTTP API (expensive endpoints, per-key limits, retry storms) belong to DDoS testing for APIs. Neither is repeated here. This post covers what only a long-lived connection does.
| What request-based testing assumes | What a WebSocket does instead | What the test has to measure |
|---|---|---|
| Load is requests per second | Load is open sockets and the state each holds | memory per idle socket, and the socket count at which a node fails |
| A request has a boundary to rate limit | After the 101 there are only frames | whether per-socket message and payload limits exist at all |
| Idle connections are suspicious | Idle is the normal state | how long an unauthenticated or silent socket may live |
| A request is logged when it ends | One log line per connection, at close | whether anyone can see the attack while it runs |
| Balancing happens per request | Balancing happens once, at connect | where sockets land after a node is lost or added |
| A restart drops a few in-flight requests | A restart disconnects every client on the node | the reconnect rate the handshake path survives |
Why do the usual counters look healthy while the service dies?
Because the counters measure requests, and a WebSocket attack is mostly made of things that are not requests.
One request per connection, logged at the end
The handshake is a GET with Upgrade: websocket and a Sec-WebSocket-Key, answered 101 Switching Protocols. That response is the last HTTP event in the connection's life. In nginx's access log, an upgraded connection appears once, with status 101, when it closes. A socket opened by an attacker at 09:00 and held until 17:00 is invisible in the log for the whole working day.
The same applies to request-rate dashboards, CDN analytics and most autoscaling signals built on requests per second. They report the handshake rate. Ten thousand sockets that each send a thousand messages a second look identical to ten thousand sockets that send nothing.
CPU stays low while memory climbs
An idle socket costs almost no CPU. What it costs is memory: kernel socket buffers that grow with unread or unsent data, the application's per-connection objects, read and write buffers, any subscription state, and in many runtimes a dedicated coroutine or two.
So the failure shape is a slow climb in resident memory with CPU near idle and p99 latency normal, until the process is killed by the OOM killer or its container limit. Autoscaling policies tied to CPU or request rate never fire, because neither moved.
How much memory does one open socket cost?
More than most teams have measured, and it varies by an order of magnitude with configuration. The number that matters is the per-socket cost on your own stack, measured idle and measured busy.
What sits behind every socket
A file descriptor and a kernel socket, which stay small while idle and grow as buffers fill. The framework's connection object and its buffers: gorilla/websocket, for example, defaults to 4 KB read and 4 KB write buffers per connection unless a buffer pool is configured. In the read-pump and write-pump pattern common in Go servers, two goroutines per connection, each starting with a small stack that grows. Then your own state: the user identity, the channels or rooms the socket has joined, and any per-socket queue of outbound messages.
None of that is large alone. Multiplied by a hundred thousand sockets on one node, it is the node's memory budget.
Compression multiplies it
The permessage-deflate extension (RFC 7692) compresses each message, and by default it keeps the compression context between messages, because that is what makes small repetitive messages compress well. Keeping a context means keeping zlib's state per connection.
zlib's own documentation gives the cost. A deflate context at the default windowBits of 15 and memLevel of 8 needs about 256 KB, and an inflate context about 32 KB plus a few more. With context takeover in both directions, that is roughly 300 KB per socket before your application stores anything. At 50,000 sockets that is about 15 GB.
This is why the Node.js ws library ships with permessage-deflate disabled on the server and warns about its memory and performance cost. The extension's own parameters (server_no_context_takeover, client_no_context_takeover, server_max_window_bits) trade compression ratio for memory. Whether they are set is a configuration fact a test confirms by measuring, not by reading.
Measure the slope, not the ceiling
The useful test number is memory per added socket. Open sockets in steps of a known size, hold each step until memory settles, and record the slope. Do it twice: once with sockets idle, once with a realistic message rate and every subscription the client normally makes.
The slope times the node's memory limit gives the socket count at which the node dies. That number is the capacity figure for a WebSocket tier, and it is usually lower than the file descriptor limit everyone quotes.
Is idle-connection exhaustion just Slowloris over WebSockets?
No, and the difference is what makes it hard to defend. A low-and-slow attack against HTTP works because the server waits for a request that is never completed, and the defence is a timeout: a request that takes too long is cut off.
On a WebSocket, nothing is incomplete. The handshake finished, the connection is legitimate at the protocol level, and silence is what a normal client does between events. A chat user can leave a tab open overnight. A timeout aggressive enough to shed a hostile idle socket also disconnects real users.
The heartbeat keeps both kinds alive
Every proxy and balancer in the path has an idle timeout. nginx closes a proxied WebSocket after proxy_read_timeout (60 seconds by default) with no data, and AWS's Application Load Balancer has a 60-second default idle timeout. So real-time frameworks send heartbeats: RFC 6455 ping and pong control frames, or an application-level ping. Socket.IO, for instance, pings every 25 seconds by default.
An attacker's client answers pings too. Once the heartbeat exists, an idle hostile socket and an idle real one are indistinguishable by timing. The TCP connection flood definition covers the general case of completed connections held open; what WebSockets add is that the application layer actively helps keep them alive.
The heartbeat is also a floor
Heartbeats cost something, and the cost scales with sockets, not users' activity. 100,000 connections pinged every 25 seconds is 4,000 pings and 4,000 pongs a second through the proxy, the balancer and the application, with no one doing anything. A connection-holding attack raises that floor without sending a single message of its own.
Unauthenticated sockets are the cheap ones to hold
Browsers cannot set an Authorization header on a WebSocket; the browser API does not allow custom headers. So applications authenticate in one of three ways: a cookie on the handshake, a token in the query string, or a token sent as the first message after the connection opens.
The third pattern means an anonymous socket exists, holds memory and receives heartbeats, before the server knows who it is. If there is no deadline for that first message, an attacker never sends it. A test should open sockets that never authenticate and record how long the server keeps them.
What can rate limiting see after the handshake?
Only what the application chooses to count. Edge WAF rules, CDN rate limits and the request limiters in a reverse proxy evaluate the upgrade request. After the 101, frames flow inside one TCP connection, and a limiter built on requests has nothing to count.
Per-socket message rate
A single authenticated socket can send as many messages as the server will read. Unless the application keeps a token bucket per connection, one client can drive a message handler at the rate of its own network link. The rate limiting concepts are the same as anywhere; the difference is that here they have to live in your message loop, because no other layer can see the messages.
Payload size
Defaults here are wide apart. The Node.js ws server accepts messages up to 100 MiB unless maxPayload is lowered. Socket.IO caps a message at 1 MB with maxHttpBufferSize. gorilla/websocket applies no read limit at all until the application calls SetReadLimit.
A message can also arrive as many continuation frames, and the server holds the fragments until the final one. A limit on frame size alone does not bound the memory a slow, fragmented message occupies. The test sends oversized and slowly fragmented messages and checks that the server closes with 1009 (message too big) instead of buffering.
Fan-out turns one message into thousands
A message to a room or channel is written once per subscriber. An inbound rate that looks harmless becomes an outbound rate multiplied by the audience. One client allowed to post ten messages a second into a channel with 20,000 subscribers produces 200,000 outbound writes a second.
So the limit that matters is often on publishing to large audiences, not on the connection. Tests should include a client publishing at its permitted rate into the largest channel the application allows.
What happens to messages a client will not read?
They queue on the server. This is the WebSocket form of a slow read attack, and broadcast makes it far worse than the HTTP version.
When the server writes to a socket faster than the client reads, the kernel send buffer fills and then the framework's own outbound queue grows. In ws this is visible as bufferedAmount climbing. If nothing caps it, every slow subscriber accumulates a copy of every broadcast it has not read, in server memory.
A few thousand clients that subscribe to busy channels and then read at a few bytes a second can exhaust a node without sending anything. The defensive pattern is a per-socket outbound limit with a policy for breaching it: drop messages, or close the socket. Which one your server does, and at what size, is something to observe under test rather than assume.
What does a handshake flood cost that a message flood does not?
Everything the server does once per connection. A new WebSocket is usually the most expensive event in its life: a TLS handshake, an HTTP upgrade, authentication (a token verification or session lookup), joining rooms, and often an initial state snapshot so the client can render before the first live event arrives.
A flood of new connections exercises all of it at once. Two limits are worth testing directly.
Connections per identity
Can one token or account open 10,000 sockets? Many services rate limit logins but never count how many concurrent connections a valid credential holds. A per-user concurrent connection cap is cheap to implement and cheap to test.
Origin on the handshake
CORS does not apply to WebSockets. A browser sends an Origin header on the handshake and leaves the decision to the server. A server that does not check it will accept connections opened by any web page, which means a third-party page can enlist its own visitors' browsers to open sockets to your endpoint. Checking Origin against an allow-list closes that route for browser traffic; it does nothing against non-browser clients, which can send any header.
Why does losing one node take down the others?
Because WebSocket connections are balanced once, when they are opened, and then stay where they landed. A load balancer that does least-connections or round-robin distributes new sockets well. It cannot move an existing one.
The survivors inherit the whole cohort
When a node dies or is restarted, every client on it disconnects in the same instant. They reconnect to the survivors, who now hold their own sockets plus a share of the lost node's, and pay the full connection cost for each of them at once. If the survivors were running at 60% of their socket limit, losing one node in three puts them at 90%. Losing a second finishes the job.
Scaling out does not help the nodes already full
A new node, or the restarted one, joins with zero sockets. It receives only new connections. The full nodes stay full until their clients disconnect on their own, which for a real-time service may be hours. Autoscaling adds capacity in the wrong place, and a test that only measures whether new nodes appear will report success.
Rebalancing has to be deliberate: the server closes a fraction of its connections with a status that tells clients to reconnect (1012 service restart, or 1013 try again later), and the balancer places them on the emptier nodes.
The reconnect storm is self-inflicted
How fast the lost cohort comes back depends on client code you wrote. The browser WebSocket API has no reconnect logic at all; applications add it, and the simplest version reconnects in the onclose handler with no delay. Socket.IO's client reconnects with a randomised backoff by default, starting around one second.
How synchronised fleets behave, and why jitter fixes it, is covered in DDoS testing for mobile backends. What is specific here is the trigger: on a WebSocket service, your own deploy is the event that disconnects a whole node at once. A rolling restart is a sequence of reconnect storms.
The chart is arithmetic. 50,000 clients reconnecting inside one second ask for tens of thousands of handshakes a second from a path sized for normal churn. Spread over 30 seconds, the same clients need about 1,700 a second. The clients did not change; only their timing did.
What changes when the transport falls back to polling?
The service becomes a request-driven API again, at a much higher request rate. Socket.IO, by default, opens a session with HTTP long-polling and then upgrades to WebSocket. If the upgrade is blocked by a proxy or a mitigation rule, clients stay on polling, and every message becomes an HTTP request.
Two consequences follow. A mitigation that blocks upgrades can multiply the request load it was meant to reduce. And polling with more than one node requires sticky sessions, because each poll must reach the node holding the session; without them, clients see Session ID unknown errors and reconnect, which adds to the handshake load. Test the fallback path deliberately, with upgrades blocked, before an incident tests it for you.
Server-Sent Events share the persistent-connection economics (one long response per client, memory per open stream, the same failover behaviour) without the inbound message path, so most of this post applies to them minus the payload and message-rate section.
How do you run a DDoS test on a WebSocket service?
With connection-oriented load, not request-oriented load. Tools that model a virtual user as a loop of requests will not hold sockets open the way real clients do. k6's WebSocket module, Artillery's WebSocket engine and Tsung can all hold persistent connections; whichever you use, check that each virtual user keeps its socket open and answers pings for the duration.
Two practical limits apply to the load generator itself. One source address can open only about 28,000 connections to a single destination address and port with Linux's default ephemeral range, so holding a few hundred thousand sockets needs many source addresses. And the generator's own memory per socket matters as much as the target's.
Containment (scoped targets, ceilings, abort criteria) is covered in DDoS testing without disrupting production. The test plan for a WebSocket tier, in order:
- Measure memory per socket. Step open sockets up in fixed increments, idle and then with realistic traffic and subscriptions, and record the slope. Repeat with
permessage-deflateon, if clients negotiate it. - Find the socket ceiling per node. Hold sockets until a fresh client cannot connect or the process dies, and record which limit was reached first: memory, file descriptors, or a proxy in front (the nginx and load balancer post covers the proxy's own slot arithmetic).
- Hold silent and unauthenticated sockets. Open connections that never authenticate and never send, answer pings, and record how long the server keeps them.
- Exceed the message limits. From one authenticated socket, send above the expected message rate, send oversized messages, and send slowly fragmented ones. Record whether the server throttles, closes with 1008 or 1009, or buffers.
- Publish into the largest channel. At the permitted rate, publish to the biggest audience the application allows and measure outbound writes and latency for other subscribers.
- Add slow readers. Subscribe clients to busy channels that read at a trickle, and watch per-socket outbound queues and node memory.
- Flood the handshake. Ramp new connections per second through the full path, including TLS, authentication and initial state, and find the rate at which connect latency climbs.
- Kill a node under load. With sockets near normal peak, stop one node, measure the reconnect rate on the survivors and their memory, then bring the node back and check where sockets end up.
- Block the upgrade. Force clients onto the polling fallback and measure the request rate that results.
Watch open sockets, resident memory per process, outbound queue sizes and handshake latency throughout. Requests per second will tell you very little.
FAQ
What is DDoS testing for WebSockets?
It is a controlled test of a real-time service's connection-level limits: memory per open socket, the socket count at which a node fails, whether message and payload limits exist after the handshake, how fast the service can accept new connections, and how it behaves when a node is lost and its clients reconnect. It complements request-based testing, which cannot see most of these.
Why can't a WAF or CDN rate limit protect a WebSocket service on its own?
They evaluate the HTTP upgrade request. After the server answers 101, the connection carries frames, not requests, and those frames are outside what a request-based rule or rate limit counts. Per-socket message limits, payload caps and outbound queue limits have to be enforced by the application.
Should a WebSocket server close idle connections?
Only with care, because idle is the normal state for a real-time client. An authentication deadline for new sockets, a per-user concurrent connection cap and a heartbeat that detects dead peers are safer than an aggressive idle timeout, which disconnects real users as readily as hostile ones.
Does permessage-deflate make a WebSocket service easier to exhaust?
It can. With compression context kept between messages, each connection holds zlib state, roughly 300 KB per socket at zlib's defaults. That multiplies the memory per socket and lowers the connection count at which a node runs out. The extension's no-context-takeover and window-bits parameters reduce the cost.
Why does autoscaling not fix a WebSocket overload?
Existing connections stay on the node they were placed on, so a new node only receives new connections while the overloaded ones stay full. And the usual triggers, CPU and request rate, often do not move when the pressure is open sockets and memory. Rebalancing needs the server to close some connections deliberately so clients reconnect elsewhere.
The unit of load is the connection
Most of what goes wrong with a real-time service under attack follows from one change of unit. Capacity planning, rate limits, dashboards, autoscaling and logging were all built around the request, and the WebSocket retired the request after its first exchange.
The connection is now what costs memory, what gets balanced, what gets lost in a deploy and what an attacker holds. A test plan that counts connections, measures what each one costs and watches where they go after a failure is a test of the service. One that counts requests is a test of the handshake.
