Connections and slow clients
The limits above bound what a request may ask for once it has arrived.
The limits above bound what a request may ask for once it has arrived. A client can also hurt a server without sending
a complete request at all: it opens thousands of sockets, sends one header line every few seconds, uploads a body one
byte at a time, keeps an idle connection for hours, or asks for a large file and never reads it. Node's own defaults
leave most of this open, so serve() from @shadow-js/dusk/node sets a limit for each case. A call with no options
already has all of them, and each can be changed or turned off with its own option.
serve() option |
Default | What it bounds | When it is exceeded |
|---|---|---|---|
maxConnections |
10 000 | Sockets open at once, WebSocket connections included | The new connection is closed before a byte is read; handle.droppedConnections counts it |
headersTimeoutMs |
30 000 | Time from the first byte of a request to the end of its headers | 408 and the socket closes |
requestTimeoutMs |
120 000 | Time to receive the whole request, headers and body | 408 and the socket closes; request.signal is aborted |
keepAliveTimeoutMs |
5 000 | An idle connection between two requests | The socket closes |
maxRequestsPerSocket |
1 000 | Requests one keep-alive connection may carry | The last answer says Connection: close; a request pipelined beyond the limit gets 503 |
writeTimeoutMs |
60 000 | A response that makes no progress because the client does not read it | The socket is destroyed, the body stream is canceled, handle.droppedSlowClients counts it |
0 turns a limit off. Any other value must be a whole number; a negative number, a fraction or a string is a
TypeError that names the option, and a misspelled option name is refused as unknown like every other serve() option.
import { serve } from "@shadow-js/dusk/node";
import { handler } from "./server-entry";
const server = await serve(handler, {
port: 3000,
host: "0.0.0.0",
maxConnections: 2_000, // below the process's file descriptor limit
headersTimeoutMs: 10_000, // browsers send headers at once; a slow link needs seconds, not a minute
requestTimeoutMs: 600_000, // this app accepts uploads over slow links
writeTimeoutMs: 30_000,
});
// Both counters are for your metrics: a rising number is a client you are being asked to hold.
setInterval(() => {
console.log(server.droppedConnections, server.droppedSlowClients);
}, 60_000).unref();
The two request deadlines are not the same as security.limits.requestTimeoutMs from the section above. That one
belongs to the handler: it aborts event.signal and answers 504 when your code takes too long to answer. The serve()
ones belong to the socket and measure how long the client takes to send. Both exist, and the shorter one wins for the
request it applies to.
What each limit is for#
Header and body deadlines. The classic slowloris client opens a connection and sends a header line every few
seconds. An idle timer never fires for it, because the connection is never idle. headersTimeoutMs and
requestTimeoutMs are deadlines from the start of the request, so trickling does not extend them. Node checks
deadlines on a timer whose default period is 30 seconds, which would let a 30 second deadline last for a minute;
serve() sets the period to a quarter of the shorter deadline (at most five seconds) so the deadline you set is the one
you get. A client that is still sending when requestTimeoutMs ends is cut off, so an app that accepts uploads over
slow connections should raise it. The body of a route that reads it is a stream: when the connection closes,
request.signal aborts and a pending request.text() or request.formData() rejects, so the handler does not wait for
bytes that will not come.
The connection cap. A connection above maxConnections is closed straight away; nothing is parsed and no handler
runs. The default of 10 000 protects the process from running out of file descriptors, which most hosts limit lower than
that. Set it below ulimit -n, with room for the files and upstream connections your code opens. Behind a proxy the
proxy is usually the one that should hold the cap, and maxConnections then only guards the port when something reaches
it directly. The cap counts every socket, so a WebSocket occupies a slot for as long as it is open. A per-client cap
for WebSockets is streams.wsMaxConnectionsPerAddress.
Keep-alive. An idle connection is closed after keepAliveTimeoutMs (five seconds), and a connection carries at most
maxRequestsPerSocket requests (1 000). Both keep a client from holding one socket for as long as it likes. Node
answers the last request of a socket with Connection: close but leaves the closing to the client or to the keep-alive
timeout, so the socket can stay for up to keepAliveTimeoutMs after the final answer. If your proxy keeps upstream
connections open longer than the server does, it will see an occasional reset on a reused connection: keep the proxy's
upstream idle timeout below keepAliveTimeoutMs, or raise this one above it.
The slow reader. A client that asks for a large download or a long stream and then stops reading would hold the
response, its buffers and the render that feeds it for as long as it liked. writeTimeoutMs is Node's socket timeout
(no read and no completed write for that long), applied only while bytes are waiting to be written. When it fires and
bytes are waiting, the client is not reading: the socket is destroyed, the response closes, the body stream is canceled
and the render stops. The error that reaches onError has code: "ERR_DUSK_SLOW_CLIENT", and the default error sink
ignores it the way it ignores a browser that navigated away, so the console stays quiet under an attack; a custom
onError sees it and can count it.
A handler that is merely slow is not a slow reader: when the timer fires and nothing waits to be written (a database query that takes a minute, a server-sent events stream between two events, an upload still arriving), it is armed again and the connection stays. An idle keep-alive socket and a connection that never sent a request are closed by the timer as before. An upgraded WebSocket is outside the timeout: its liveness is the ping and pong of the stream, not a socket timer.
One more detail makes the timeout fair to slow but honest clients. A response body made of one huge chunk is one write
to the socket, and a write completes only when the client has read all of it, so a client reading it at a steady
pace would look stalled. serve() writes bodies in slices of 64 KiB, which makes progress visible every 64 KiB, and
the static file server already reads in slices of that size. A client therefore has to take roughly 64 KiB in
writeTimeoutMs, about 1 KiB per second at the default, to stay connected. Raise it for a link that is slower than that.
Turning a limit off#
Each limit is a default, not a requirement. Turn one off when something else already owns it:
import { serve } from "@shadow-js/dusk/node";
import { handler } from "./server-entry";
// A platform router that already caps connections, times out slow clients and closes idle sockets.
await serve(handler, {
maxConnections: 0,
maxRequestsPerSocket: 0,
writeTimeoutMs: 0,
});
Leave the header and body deadlines on even then unless the proxy buffers whole requests before it forwards them: a proxy that streams a request through passes the slow client's pace straight on to your process.
Production checklist additions#
-
serve()runs with its connection limits, or a proxy in front of it enforces the same ones: a connection cap, a header and body deadline, a short idle keep-alive, and a timeout for a client that does not read (Connections and slow clients). -
maxConnectionsis below the process's file descriptor limit, andrequestTimeoutMsfits your slowest legitimate upload.
Residual note for the threat model#
R-3 is closed for a process that runs serve(): the caps and deadlines above are on by default. They bound a single
process; a flood of connections from many addresses is still a job for the proxy, the load balancer or the platform's
network protection, which can drop traffic before it reaches your sockets.