Handling Client Disconnects in ASP.NET Core SSE Permalink to this section

Part of ASP.NET Core SSE Implementation, under Backend Stream Generation & Connection Management.

Every open Server-Sent Events stream holds a subscription, a buffer and usually a slot in some registry. When the client goes away, all of it must be released. ASP.NET Core gives you a precise signal — HttpContext.RequestAborted — but only if the handler observes it, and only once Kestrel has noticed the connection is gone. This guide covers both halves: making cancellation reach your cleanup code, and making sure dead connections are noticed promptly.

Symptom & Developer Intent Permalink to this section

  • The number of registered subscribers only grows; a restart is the only thing that resets it.
  • Memory climbs slowly over days on a streaming service with a stable number of real users.
  • Logs show OperationCanceledException or IOException: The client reset the request stream escaping as unhandled errors.
  • Broadcasts get slower over time as they iterate an ever-larger list of mostly dead subscribers.
  • Metrics say 40,000 connected clients; the load balancer says 9,000.

The intent is that every subscription created for a stream is released within seconds of the client leaving, by any route, with no error noise in the logs.

Root Cause Analysis Permalink to this section

A disconnect reaches your code in two steps. First Kestrel must learn the connection is gone: a clean close (FIN) or reset (RST) from the client is noticed immediately; a vanished client — a laptop that slept, a phone that lost signal — sends nothing, and the server learns only when a write fails or TCP keepalive gives up, which by default takes hours. Second, Kestrel cancels RequestAborted, and your code must be awaiting something that honours that token.

How long until the server notices a vanished client Timeline comparing detection time for a clean close, a vanished client with heartbeats every 15 seconds, and a vanished client with no writes at all. How long until the server notices a vanished client Clean close (FIN) Vanished, 15 s heartbeat Vanished, no writes found on a failed write not found (hours) 0 24 48 72 96 120 seconds after the client disappears write fails
Clean closes are immediate. A vanished client is only discovered by a failed write, so the heartbeat interval sets the detection time.

Leaks come from subscriptions whose cleanup is not tied to that cancellation: an event handler attached with += and never detached, a dictionary entry added in the handler and removed only on a code path that an exception skips, or a background task started per connection that is not passed the token.

Step-by-Step Resolution Permalink to this section

Step 1 — Tie every per-connection resource to a try/finally Permalink to this section

app.MapGet("/api/stream", async (HttpContext ctx, EventBus bus, CancellationToken ct) =>
{
    ctx.Response.ContentType = "text/event-stream";
    var channel = Channel.CreateBounded<string>(256);
    void OnEvent(string frame) => channel.Writer.TryWrite(frame);

    bus.Published += OnEvent;                           // acquired…
    try
    {
        await foreach (var frame in channel.Reader.ReadAllAsync(ct))
        {
            await ctx.Response.WriteAsync(frame, ct);
            await ctx.Response.Body.FlushAsync(ct);
        }
    }
    catch (OperationCanceledException) when (ct.IsCancellationRequested)
    {
        // Normal: the client left. Not an error.
    }
    finally
    {
        bus.Published -= OnEvent;                       // …always released
        channel.Writer.TryComplete();
    }
});

The minimal API binds CancellationToken ct to HttpContext.RequestAborted, so ReadAllAsync(ct) ends the moment Kestrel detects the disconnect. The finally runs on every exit path — cancellation, a write failure, or a bug.

Step 2 — Make idle connections detectable with heartbeats Permalink to this section

A stream with nothing to send never writes, so a vanished client is never discovered. Merge a periodic comment into the loop:

using var hb = new PeriodicTimer(TimeSpan.FromSeconds(15));
var read = channel.Reader.WaitToReadAsync(ct).AsTask();
var tick = hb.WaitForNextTickAsync(ct).AsTask();

while (true)
{
    var done = await Task.WhenAny(read, tick);
    if (done == tick)
    {
        await ctx.Response.WriteAsync(": hb\n\n", ct);   // fails fast if the peer is gone
        tick = hb.WaitForNextTickAsync(ct).AsTask();
    }
    else
    {
        if (!await read) break;
        while (channel.Reader.TryRead(out var frame)) await ctx.Response.WriteAsync(frame, ct);
        read = channel.Reader.WaitToReadAsync(ct).AsTask();
    }
    await ctx.Response.Body.FlushAsync(ct);
}

A write to a dead peer does not always fail on the first attempt — the kernel may accept bytes into the send buffer — but after a few heartbeats with no acknowledgement the connection resets and the write throws. With 15-second heartbeats, dead connections are typically found within 30 to 60 seconds.

A heartbeat surfaces a dead connection Sequence diagram of a handler sending heartbeats to a client that has silently vanished, the writes eventually failing, RequestAborted being cancelled and the finally block releasing the subscription. A heartbeat surfaces a dead connection Handler Kestrel Vanished client : hb (accepted into send buffer) no ACK : hb connection reset → RequestAborted finally: unsubscribe
The heartbeat is not only for proxies. It is the probe that turns a silent disappearance into a cancellation your code can react to.

Step 3 — Never start per-connection work without the token Permalink to this section

// Wrong: runs forever after the client leaves.
_ = Task.Run(() => PollUpstream(userId));

// Right: stops when the request is aborted.
var poller = Task.Run(() => PollUpstream(userId, ct), ct);

And never block on the token-less overloads of WriteAsync or FlushAsync in a stream handler; always pass ct.

Step 4 — Keep log noise out of normal departures Permalink to this section

Client disconnects are the normal end of every stream. Catch the cancellation exception filtered on the token, as in step 1, and lower the log level of Kestrel’s connection-reset messages:

{
  "Logging": {
    "LogLevel": {
      "Microsoft.AspNetCore.Server.Kestrel.Connections": "Warning",
      "Microsoft.AspNetCore.Server.Kestrel": "Warning"
    }
  }
}

Step 5 — Count subscriptions and compare with connections Permalink to this section

Expose a gauge of active subscriptions from the registry and compare it with Kestrel’s current-connections counter. The two should track each other; divergence is a leak.

var meter = new Meter("MyService.Streams");
meter.CreateObservableGauge("streams.active", () => registry.Count);

Validation & Monitoring Permalink to this section

# 1. Open 100 streams, kill them abruptly, and watch the gauge return to baseline.
for i in $(seq 1 100); do curl -sN http://localhost:5000/api/stream > /dev/null & done
sleep 5; kill -9 $(jobs -p)
dotnet-counters monitor -n MyService --counters MyService.Streams,Microsoft-AspNetCore-Server-Kestrel

# 2. Simulate a vanished client: drop packets instead of closing.
sudo iptables -A OUTPUT -p tcp --sport 5000 -j DROP   # on a test host only

With packet dropping in place, the gauge should fall back within about a minute as heartbeats fail. Remove the rule after the test.

Registered subscriptions over a week Line chart over seven days of registered subscriptions for a service without cleanup on disconnect, which grows steadily, and with try/finally cleanup and heartbeats, which follows daily traffic. Registered subscriptions over a week no cleanup finally + heartbeats 0 15 30 45 60 0 1.4 2.8 4.2 5.6 7 days registered subscriptions (thousands)
Real concurrency rises and falls with the working day. A registry that only rises is counting ghosts.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Why is RequestAborted not cancelled when the user closes their laptop?

Because nothing reaches the server when a device vanishes. Kestrel learns the connection is dead only when a write fails, so an idle stream needs heartbeats for RequestAborted to fire in reasonable time.

Should I enable TCP keepalive instead of heartbeats?

TCP keepalive defaults to probing after two hours, and proxies in the path do not forward it. Application heartbeats work end to end and through every intermediary, so use them; keepalive can be a secondary safety net.

Is it safe to write to the response after RequestAborted fires?

Writes will throw or be ignored. Stop writing, release resources, and return. Do not try to send a final event; the client is gone.

How do I test disconnect handling in CI?

Use WebApplicationFactory, open a stream with ResponseHeadersRead, dispose the response to disconnect, and assert that the registry count returns to its previous value within a short timeout.