Skip to content

Defending Async Servers Against Slow Clients

An asyncio server handles idle connections cheaply — a suspended task and a socket each — which is why a slow-client ("slowloris") attack does not exhaust its CPU or memory the way it exhausts a thread-per-connection server. What it exhausts instead is file descriptors: every connection holds one, and a process has a hard limit. Measured on Python 3.14 against a minimal ASGI app, with clients that opened a connection, sent a partial request header, and then sent one more header line every five seconds without ever finishing: with its defaults, uvicorn 0.54 kept all 1,000 such connections open for 30 seconds and closed none, though it still served ordinary requests in a median of 2.0 ms. With the process's open-file limit at 1,024 — a common container default — 1,100 slow clients used up every descriptor and 0 of 20 ordinary requests succeeded. Adding --limit-concurrency 500 changed nothing, because the slow connections never complete a request to be counted. Hypercorn 0.18 with its defaults closed every slow connection after its 5-second keep-alive timeout and served 15 of 15 requests under the same attack. This guide closes slow connections before they close the server.

Prerequisites

1. Reproduce a slow-client attack against your server

A slow client needs only a socket and patience; reproduce it with asyncio:

async def slow_client(host: str, port: int) -> None:
    reader, writer = await asyncio.open_connection(host, port)
    writer.write(b"GET / HTTP/1.1\r\nHost: x\r\n")         # headers never finished
    await writer.drain()
    try:
        while True:
            await asyncio.sleep(5)
            writer.write(b"X-a: b\r\n")                       # keep the connection busy
            await writer.drain()
    except ConnectionError:
        pass                                                   # the server closed it


async def attack(host: str, port: int, n: int) -> None:
    await asyncio.gather(*(slow_client(host, port) for _ in range(n)), return_exceptions=True)

While the attack runs, issue an ordinary request every second and record whether it succeeds, and sample the server process's open file descriptors (psutil.Process(pid).num_fds()). Measured against uvicorn with a generous descriptor limit: all 1,000 slow connections stayed open for the full 30 seconds, the server held 1,014 descriptors, and ordinary requests still succeeded — asyncio was not overwhelmed by idle connections. The danger is the limit, not the load.

Verify: run the attack in staging at a connection count just above the server's ulimit -n, and observe what happens to ordinary requests.

Slow clients against two ASGI servers A grid of 4 rows by 4 columns. Slow clients against two ASGI servers server, settings slow clients slow conns closed by server real requests served uvicorn 0.54, defaults, high fd limit 1,000 for 30 s 0 30 of 30 (median 2.0 ms) uvicorn 0.54, fd limit 1,024 1,100 for 20 s 90 0 of 20 uvicorn, fd limit 1,024, --limit-concurrency 500 1,100 for 20 s 90 0 of 20 Hypercorn 0.18, defaults, fd limit 1,024 1,100 for 20 s 1,100 15 of 15 Python 3.14; each slow client sent one header line every 5 s and never finished its request.

2. Know which timeout covers which phase

Servers have several timeouts, and only some apply to a connection that has not finished sending its request:

# uvicorn
uvicorn.run(app, timeout_keep_alive=5)        # idle time *between* requests on a kept-alive connection
uvicorn.run(app, limit_concurrency=500)       # concurrent requests/connections before 503s
# Neither closed connections that were still sending their first request headers.

# Hypercorn
config = hypercorn.config.Config()
config.keep_alive_timeout = 5.0               # also closed connections stuck before a complete request

Measured: uvicorn closed none of the slow connections over 30 seconds with its default timeout_keep_alive of 5 seconds, and --limit-concurrency 500 did not reduce the number held — neither setting governs the phase before a complete request has arrived. Hypercorn's keep_alive_timeout (5 seconds by default) did close them: every slow connection was gone after a few seconds, and the descriptor count returned to 7. Behaviour like this changes between server versions, so treat the measurement as a test to repeat on yours rather than a permanent property of either server.

Verify: for your server and version, a connection that sends half a request and stalls is closed within a known time.

3. Put a proxy with header timeouts in front

The most reliable defence is a component built for hostile clients: a reverse proxy or load balancer that buffers requests and enforces header and body timeouts before anything reaches Python:

server {
    listen 443 ssl;
    client_header_timeout 10s;     # time allowed to send the request headers
    client_body_timeout   10s;     # time between body reads
    client_max_body_size  10m;
    keepalive_timeout     30s;
    location / {
        proxy_pass http://127.0.0.1:8000;      # uvicorn sees only complete requests
        proxy_request_buffering on;
    }
}

With request buffering, the proxy absorbs slow uploads and slow headers, and the application server receives complete requests over a fast local connection — the slow-client problem disappears from the Python process entirely. Managed load balancers provide the same protection with their own idle and header timeouts. This is the same division of labour as terminating TLS at the edge, discussed in TLS & DNS.

Verify: the slow-client test against the proxy's address leaves the application server's descriptor count unchanged.

Where slow clients should be stopped A flow of 4 stages. Where slow clients should be stopped slow client dribbles headers reverse proxy client_header_timeout 10 s buffered request complete, forwarded fast ASGI server fds held only by real requests The proxy absorbs the slowness; Python sees complete requests.

4. Raise the descriptor limit and watch it

Whatever else is in place, the descriptor limit should be far above expected concurrent connections, and the current count should be visible:

import resource


def raise_nofile(target: int = 65_536) -> int:
    soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
    new = min(target, hard)
    if soft < new:
        resource.setrlimit(resource.RLIMIT_NOFILE, (new, hard))
    return new


async def report_fds(interval: float = 10.0) -> None:
    proc = psutil.Process()
    limit = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
    while True:
        OPEN_FDS.set(proc.num_fds())
        FD_LIMIT.set(limit)
        await asyncio.sleep(interval)

Measured: at a soft limit of 1,024, eleven hundred slow connections were enough to deny service completely. On the test host the hard limit was 524,288, so the process could have raised its own soft limit at startup; in containers the limit is set by the runtime (--ulimit nofile=... in Docker, the container runtime's defaults in Kubernetes). A raised limit turns an easy attack into an expensive one, but it is a cushion, not a fix — the connections still need timeouts. The descriptor gauge also catches ordinary leaks, as in detecting leaked sockets and file descriptors.

Verify: the service logs its descriptor limit at startup, and an alert fires when open descriptors exceed 70% of it.

5. Bound what complete requests can hold, too

Slow clients also come with complete headers and a slow body, or with a request that is fast but asks for a slow streaming response. Bound those in the application where the server does not:

async def app(scope, receive, send):
    if scope["type"] != "http":
        return
    try:
        async with asyncio.timeout(15):                        # whole request, including body
            body = await read_body(receive, limit=10 * 2**20)  # size limit, too
    except TimeoutError:
        await send({"type": "http.response.start", "status": 408, "headers": []})
        await send({"type": "http.response.body", "body": b""})
        return
    await handle(scope, body, send)

A request-level timeout covers slow bodies once the server has handed the request to the application, and the size limit stops large uploads from occupying memory, as in limiting request body size in ASGI apps. For responses, the server's write path applies backpressure to slow readers, and a deadline on the handler — as in enforcing request timeouts in ASGI servers — keeps a slow reader from holding a streaming response open indefinitely.

Verify: a client that sends headers and then trickles its body is answered with 408 within the request timeout.

Which defence handles this kind of slow client? A decision on Where in the request does the client stall with 4 outcomes. Which defence handles this kind of slow client? Where in the request does the client stall? before headers complete proxy header timeout, or a server that closes them uvicorn kept them while sending the body proxy body timeout or app-level request timeout 408 while reading the response backpressure + handler deadline bounded stream any of the above high fd limit + fd alert 1,024 fell to 1,100 clients Measure your server's behaviour; timeouts differ by phase and by version.

Verification

An async server withstands slow clients when:

  • Something closes connections that never complete their headers — a proxy, or a server measured to do so.
  • The descriptor limit is high and monitored, with alerts well before it is reached.
  • Request bodies have time and size limits, and handlers have deadlines.
  • A slow-client test runs in staging against the real deployment path.

Diagnostic Hook: track open file descriptors against established TCP connections from the load balancer. Descriptors rising while request rate stays flat means connections are being held without completing requests — the signature of slow clients or a leak — and the server logs will show nothing, because no request was ever completed.

Pitfalls & edge cases

  • Trusting keep-alive timeouts. Measured: uvicorn's did not close connections still sending headers.
  • Trusting concurrency limits. Measured: --limit-concurrency 500 did not help.
  • Default descriptor limits. Measured: 1,024 descriptors fell to 1,100 slow clients.
  • Testing only the app server. The proxy in front is part of the defence; test the full path.

Frequently Asked Questions

Is uvicorn vulnerable to slowloris attacks?

In testing with uvicorn 0.54 defaults, connections that never finished their headers stayed open indefinitely; with a 1,024 file-descriptor limit, 1,100 such clients made every ordinary request fail. Put a proxy with header timeouts in front and raise the descriptor limit.

Does --limit-concurrency protect uvicorn from slow clients?

It did not in testing: the slow connections never complete a request, and ordinary requests still failed at the descriptor limit.

Does Hypercorn close slow connections?

In testing, Hypercorn 0.18's 5-second keep-alive timeout closed connections stuck before a complete request, and it kept serving ordinary requests under the same attack.

Why doesn't asyncio run out of memory with thousands of idle connections?

Each idle connection is a suspended task and a socket — cheap. The limit that runs out is file descriptors, one per connection.