Build1 publisher3 min readPublished Updated
A closed browser tab cancels the background work once you propagate the request token
A dev.to debugging writeup traces latency spikes to a background task that ignored cancellation. The token its fix propagates is the one cancelled when the client disconnects, so adopting it means deciding which jobs are abandonable.
The Engineer · Build desk

What happened
- A dev.to post describes reviewing a .NET Core service with latency spikes under load: no immediate 500s, response times rising progressively, and a thread pool that looked exhausted.
- The endpoint validated the request, started the heavy work with a discard assignment, and returned 202 Accepted, leaving the work detached from the request that started it.
- At peak traffic the post reports 1,000 requests a second, and every one of them triggered one of these detached background jobs.
- The author's diagnosis was CPU-bound work after the HTTP call that never checked for cancellation, which with a saturated connection pool pushed the thread pool to its maximum until the system fell over.
- The prescribed fix has two parts: propagate a CancellationToken from the top-level request into the background task, and check it during the work.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision Each 202 endpoint now needs a stated answer to whether its work may be abandoned when the caller's connection drops, because the propagated token fires on exactly that event.
- constraint Token propagation leaves the per-host connection ceiling untouched, so a 50-slot pool serving 30-second calls still completes under two calls a second no matter how promptly cancelled jobs exit.
- exposure Nothing in the pattern caps how many detached jobs exist at once, so a slow dependency sets the queue depth for you, at hundreds of jobs a second at the post's load.
In the corrected sample, the token handed to the detached job, according to the post's own comment, "is canceled if the client disconnects or the server shuts down" [11]. The reason the endpoint returned 202 in the first place was that, as the author wrote, "To keep the user experience snappy, we didn't want the client to wait for the entire operation to complete" [14]. Those two sentences pull against each other. Once the job's lifetime comes from the request, the user closing the tab, which is the case the post opens the bug with [5], cancels the heavy work.
So this is a per-endpoint policy decision. If the heavy work is a recomputation the caller can ask for again, cancelling on disconnect costs nothing. If the 202 implied a durable side effect, the fixed version returns at a check of IsCancellationRequested and leaves the half-finished state where it was [12]. A discard assignment tells the compiler you meant to drop the task, and the post is explicit that the task then runs to completion whether or not anyone is listening [4].
Its own figures also show that token propagation is not what bounds the damage. Peak load was 1,000 requests a second, each starting a job [6]. The third-party call took 30 seconds [5], and the author puts the client limit at "usually 50 per host by default for HttpMessageHandler" [7]. Fifty slots turning over every 30 seconds finish about 1.67 calls a second [1]. Against arrivals of 1,000 a second, the backlog grows by roughly 998 jobs every second [2]. Cancellation drains that only when tokens fire, and the trigger on this token is the client's connection [11].
For those numbers to transfer, the slow dependency has to sit behind one host name and your handler has to carry that 50-per-host limit. The HttpClient registration is not shown in the post. If the registered handler has no per-server cap, no queue forms at the connection pool at all, and you get thousands of concurrent sockets against the slow service instead.
The author asks the right question of himself: "Wait, if async releases threads, why the exhaustion?" [15]. The answer given is the CPU-bound work after the HTTP call, which never checked whether it should stop [9]. That matters for anyone copying the fix, because awaiting the network call already returns the thread to the pool [8]. Passing the token to GetAsync does not free a thread that await had freed. The check before DoCPUIntensiveWorkAsync is where thread pool relief comes from [12].
On .NET 6 and later the token is injected into the action method; before that you read HttpContext.RequestAborted [13]. Both routes give you a request-scoped token. In my view, work that has to outlive the caller belongs behind a queued worker with its own cancellation source, and the token in this sample comes from the request.
What to watch
- Whether the author publishes the HttpClient registration, since the 50-per-host figure the queue estimate rests on depends on it.
- Reports of 202 jobs lost after client disconnects, once teams wire the injected action-method token into detached work.
- Whether follow-up writeups replace the discard assignment with a queued worker whose token comes from host shutdown.