Skip to content

Detecting Client Disconnects in ASGI Handlers

When a client gives up — a browser tab closed, a mobile app backgrounded, an upstream proxy timing out — the work it asked for is usually wasted. Whether the server notices depends on what the handler is doing. Tested on Uvicorn 0.54 with Starlette 1.7 and a client that gave up after 1 s: a plain handler sleeping for 5 s was not cancelled; it ran to completion and its response was discarded. A streaming response's generator was cancelled as soon as the server tried to write to the closed connection (0.5 s in). A handler that polled request.is_disconnected() every 100 ms noticed after 1.01 s, and a watcher that cancels the work on http.disconnect cancelled it at 1.00 s. This guide covers when disconnects are detected automatically and how to detect them yourself for expensive non-streaming work.

Prerequisites

1. Know what the server does on its own

The ASGI server learns about a disconnect when the socket closes, and tells the app through the receive channel as an http.disconnect message. It does not cancel the request task just because the client left. What the app experiences depends on its shape:

async def slow(request):
    await asyncio.sleep(5)                 # client left at 1 s: still runs to 5 s
    return JSONResponse({})                # response written to a closed socket, discarded


async def stream(request):
    async def gen():
        for i in range(100):
            yield b"chunk\n"               # cancelled once writing to the closed socket fails
            await asyncio.sleep(0.1)
    return StreamingResponse(gen())

Measured: the plain handler ran its full 5 s although the client had gone at 1 s. The streaming generator received CancelledError 0.5 s in, when the client disconnected — Starlette's StreamingResponse listens for http.disconnect while streaming and cancels the body. So streaming endpoints clean up on their own, and request-response endpoints doing expensive work do not.

Verify: log entry and exit in an expensive handler, abort the request from a client, and see whether the handler runs to completion.

What happens when the client leaves, by handler style A grid of 4 rows by 3 columns. What happens when the client leaves, by handler style handler style on disconnect measured plain handler runs to completion 4 s wasted after client left StreamingResponse generator cancelled cancelled at 0.5 s poll is_disconnected() stops at next check noticed at 1.01 s disconnect watcher work cancelled cancelled at 1.00 s Uvicorn 0.54 and Starlette 1.7: only streaming responses are cancelled for you.

2. Poll is_disconnected() between steps

For work that runs in steps — batches, pages, iterations — check between them:

async def build_report(request):
    rows = []
    for page in range(50):
        if await request.is_disconnected():
            log.info("client gone, stopping at page %d", page)
            return Response(status_code=499)      # nobody will read it
        rows += await fetch_page(page)
    return JSONResponse(rows)

is_disconnected() peeks at the receive channel without blocking, so it is cheap to call. Its precision is the step length: measured with 100 ms steps, the handler noticed 1.01 s after starting for a client that left at 1.0 s. The status code is a convention (499, borrowed from nginx); it is never seen by the departed client but shows up in your logs and metrics.

Verify: abort a request partway; the log shows the handler stopping at the next step instead of finishing.

3. Cancel the work when the client leaves

For a single long await — a slow database query, a call to another service — there are no steps to check between. Run the work and a watcher on the receive channel concurrently, and cancel the work when http.disconnect arrives:

async def cancel_on_disconnect(request, coro):
    work = asyncio.ensure_future(coro)

    async def watch():
        while True:
            message = await request.receive()
            if message["type"] == "http.disconnect":
                work.cancel()
                return

    watcher = asyncio.create_task(watch())
    try:
        return await work
    finally:
        watcher.cancel()


async def slow(request):
    try:
        result = await cancel_on_disconnect(request, expensive_query())
    except asyncio.CancelledError:
        return Response(status_code=499)
    return JSONResponse(result)

Measured: with a client timeout of 1.0 s, the expensive coroutine was cancelled at exactly 1.00 s, and a fast request through the same helper returned its result normally. The cancellation propagates into the query — asyncpg and httpx both abort cleanly when cancelled — so the database or upstream stops working too. Read the request body before starting the watcher, since the watcher consumes messages from receive.

Verify: start an expensive query, disconnect, and confirm in the database (pg_stat_activity) that the query stops rather than running to completion.

Cancelling work on http.disconnect A sequence of 6 messages between 4 participants. Cancelling work on http.disconnect client server watcher work request start expensive query closes connection at 1.0 s http.disconnect cancel() CancelledError -> 499 Measured: the work was cancelled at 1.00 s, the moment the client went away.

4. Decide what should not be cancelled

Not all work should stop when the client leaves. A payment that has been sent to the processor, an order being written, a message being published — abandoning these halfway is worse than finishing them for nobody. Protect them:

async def place_order(request):
    order = await request.json()
    # cancellable: validation and pricing are pure waste if the client is gone
    priced = await cancel_on_disconnect(request, price(order))
    # not cancellable: once started, finish it even without a client
    result = await asyncio.shield(commit_order(priced))
    return JSONResponse(result)

asyncio.shield keeps commit_order running if the surrounding task is cancelled; the client can retry with an idempotency key and find the order committed. The general rules for this split are in using asyncio.shield to protect critical sections and making background jobs idempotent.

Verify: disconnect during the commit step; the order is committed exactly once, and a retry with the same key returns it.

5. Account for proxies and HTTP/2

A disconnect is only visible if the connection the server sees actually closes. Behind a reverse proxy, the server's connection is to the proxy, and the proxy decides whether to close it when the browser leaves — nginx does by default (proxy_ignore_client_abort off), some load balancers do not. With HTTP/2, one connection carries many requests, and a cancelled request is a stream reset, which servers translate to http.disconnect for that request only.

# Middleware that counts disconnects per route, to see whether you receive them at all
class DisconnectMetrics:
    def __init__(self, app) -> None:
        self.app = app

    async def __call__(self, scope, receive, send) -> None:
        if scope["type"] != "http":
            return await self.app(scope, receive, send)

        async def receive_wrapper():
            message = await receive()
            if message["type"] == "http.disconnect":
                DISCONNECTS.labels(route=scope["path"]).inc()
            return message

        await self.app(scope, receive_wrapper, send)

If the counter stays at zero while clients are known to time out, the proxy in front is holding connections open and no amount of handler code will see the disconnect. Fix it at the proxy, or push a deadline from the client instead, as in propagating deadlines across async service calls.

Verify: with the production proxy in place, abort a request from a browser and see the disconnect counter increase.

How should this endpoint react to a lost client? A decision on What does the endpoint do with 4 outcomes. How should this endpoint react to a lost client? What does the endpoint do? streams a response nothing to add cancelled by Starlette work in steps poll is_disconnected() between steps one long await disconnect watcher cancel the work commits side effects shield + idempotency finish anyway Stop waste, but never abandon a side effect halfway.

Verification

Disconnect handling works when:

  • Expensive non-streaming endpoints stop within a step or immediately after the client leaves.
  • Downstream work stops too: cancelled queries disappear from the database.
  • Side-effecting steps finish even without a client, and are idempotent for retries.
  • Disconnects are observed in production, proving the proxy forwards them.

Diagnostic Hook: log handler duration together with whether the client was still connected at the end. A large share of long requests finishing after their client left is wasted capacity — those are the endpoints that need a watcher. Compare with your proxy's client-abort count (nginx logs status 499) to confirm the server sees what the proxy sees.

Pitfalls & edge cases

  • Assuming the server cancels handlers. Under Uvicorn it does not; the measured handler ran 4 s past its client.
  • Starting the watcher before reading the body. It consumes the body messages.
  • Cancelling commits. Shield side effects and rely on idempotent retries.
  • Proxies that hide disconnects. Check that disconnects arrive at all.

Frequently Asked Questions

Does Uvicorn cancel my handler when the client disconnects?

Not for an ordinary request-response handler: in testing, a handler kept running 4 s after its client left. Streaming responses are cancelled by Starlette once the disconnect arrives.

How do I detect a client disconnect in Starlette or FastAPI?

Call await request.is_disconnected() between steps, or run the work alongside a task that waits on request.receive() for an http.disconnect message and cancels the work.

What status code should I return when the client disconnected?

It is never delivered, so it only matters for your logs; 499, the nginx convention for client closed request, is common.

Why does my app never see disconnects in production?

A reverse proxy or load balancer may keep its connection to your server open after the browser leaves. Check the proxy's client-abort setting and count http.disconnect messages to confirm.