[]previous and next page

Connections and slow clients

The limits above bound what a request may ask for once it has arrived.

For agentsUpdated

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.

serve.tsts
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:

serve.tsts
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).
  • maxConnections is below the process's file descriptor limit, and requestTimeoutMs fits 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.