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
OperationCanceledExceptionorIOException: The client reset the request streamescaping 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.
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.
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.
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.