<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Imran Ahmed</title>
    <description>The latest articles on DEV Community by Imran Ahmed (@developerimranahmed).</description>
    <link>https://dev.to/developerimranahmed</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4119738%2Fcd8a089f-8062-4300-81c1-f84205b87d26.jpg</url>
      <title>DEV Community: Imran Ahmed</title>
      <link>https://dev.to/developerimranahmed</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/developerimranahmed"/>
    <language>en</language>
    <item>
      <title>The CancellationToken That Never Propagated: A Subtle ASP.NET Core Timeout Bug I Missed in Code Review</title>
      <dc:creator>Imran Ahmed</dc:creator>
      <pubDate>Sat, 19 Sep 2026 06:01:54 +0000</pubDate>
      <link>https://dev.to/developerimranahmed/the-cancellationtoken-that-never-propagated-a-subtle-aspnet-core-timeout-bug-i-missed-in-code-2hhp</link>
      <guid>https://dev.to/developerimranahmed/the-cancellationtoken-that-never-propagated-a-subtle-aspnet-core-timeout-bug-i-missed-in-code-2hhp</guid>
      <description>&lt;h1&gt;
  
  
  The CancellationToken That Never Propagated: A Subtle ASP.NET Core Timeout Bug I Missed in Code Review
&lt;/h1&gt;

&lt;p&gt;In the early stages of my career, I believed that if an HTTP request had a timeout set, the system was safe. I would configure &lt;code&gt;HttpClient.Timeout&lt;/code&gt; or use &lt;code&gt;RequestAborted&lt;/code&gt;, and I felt protected.&lt;/p&gt;

&lt;p&gt;I was wrong.&lt;/p&gt;

&lt;p&gt;Recently, I reviewed a codebase for a .NET Core application that was experiencing mysterious latency spikes under load. The errors weren't immediate 500s; instead, the application felt sluggish, response times increased progressively, and eventually, the thread pool looked exhausted. This post breaks down that specific debugging journey and why &lt;code&gt;CancellationToken&lt;/code&gt; propagation is more critical than simple timeouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;We had an ASP.NET Core API endpoint that accepted a request, validated it, and then triggered a long-running operation. To keep the user experience snappy, we didn't want the client to wait for the entire operation to complete. Instead, we used a pattern where the main request would return a "202 Accepted" quickly, and a background &lt;code&gt;Task&lt;/code&gt; would handle the heavy lifting.&lt;/p&gt;

&lt;p&gt;The code looked something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"process"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ProcessAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Simulate quick validation&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 

    &lt;span class="c1"&gt;// Fire and forget the heavy work&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ProcessHeavyWorkAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Accepted&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ProcessHeavyWorkAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// This calls a slow third-party API&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://slow-service.com/api/"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... do more work ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Hidden Trap
&lt;/h2&gt;

&lt;p&gt;On the surface, this looks standard. We aren't blocking the main thread. We are using &lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt;. What’s the problem?&lt;/p&gt;

&lt;p&gt;The problem lies in the &lt;strong&gt;lack of cancellation context&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Imagine a scenario where:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The client sends the request.&lt;/li&gt;
&lt;li&gt;The API returns &lt;code&gt;202 Accepted&lt;/code&gt; immediately.&lt;/li&gt;
&lt;li&gt;The background task starts &lt;code&gt;ProcessHeavyWorkAsync&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The third-party service is slow, taking 30 seconds.&lt;/li&gt;
&lt;li&gt;Meanwhile, the user closes the tab, or the load balancer times out the connection.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In a traditional synchronous world, the server would kill the request. But here, the background task is detached. It is still running on the ASP.NET Core &lt;code&gt;SynchronizationContext&lt;/code&gt; (or rather, the thread pool context). It &lt;em&gt;will&lt;/em&gt; complete, regardless of whether the client is still listening.&lt;/p&gt;

&lt;p&gt;If this happens once, no one notices. But under load?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cascade Effect
&lt;/h2&gt;

&lt;p&gt;When we hit peak traffic, 1,000 requests a second were triggering these background jobs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Job A starts, waits 30s.&lt;/li&gt;
&lt;li&gt;  Job B starts, waits 30s.&lt;/li&gt;
&lt;li&gt;  ...&lt;/li&gt;
&lt;li&gt;  Job 1000 starts, waits 30s.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thread pool has a finite number of threads. While these tasks are &lt;code&gt;await&lt;/code&gt;ing the &lt;code&gt;HttpClient&lt;/code&gt;, the threads are released back to the pool. &lt;em&gt;However&lt;/em&gt;, the &lt;code&gt;HttpClient&lt;/code&gt; has its own connection limits (usually 50 per host by default for &lt;code&gt;HttpMessageHandler&lt;/code&gt;). If you have 1,000 concurrent tasks hitting the same slow service, you saturate the connection pool.&lt;/p&gt;

&lt;p&gt;Wait, if &lt;code&gt;async&lt;/code&gt; releases threads, why the exhaustion?&lt;/p&gt;

&lt;p&gt;The issue wasn't just the HTTP call. &lt;code&gt;ProcessHeavyWorkAsync&lt;/code&gt; had some CPU-bound work &lt;em&gt;after&lt;/em&gt; the HTTP call. Because we didn't propagate a &lt;code&gt;CancellationToken&lt;/code&gt;, the code never checked if it should stop. Under extreme load, the combination of connection pool saturation and queued CPU work caused the thread pool to grow to its maximum limit. New requests couldn't get threads to even &lt;em&gt;start&lt;/em&gt; their background jobs. The system fell over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Threading the Token
&lt;/h2&gt;

&lt;p&gt;The fix required two changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Propagate the token:&lt;/strong&gt; Pass a &lt;code&gt;CancellationToken&lt;/code&gt; from the top-level request into the background task.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Respect the token:&lt;/strong&gt; Actually check it during the work.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;HttpPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"process"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ProcessAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// We use cancellationToken from the DI/ActionDescriptor&lt;/span&gt;
    &lt;span class="c1"&gt;// This token is canceled if the client disconnects or the server shuts down.&lt;/span&gt;

    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ProcessHeavyWorkAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Accepted&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ProcessHeavyWorkAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Pass the token to HttpClient&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://slow-service.com/api/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Stop processing&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Check again before CPU-heavy work&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Do CPU work&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;DoCPUIntensiveWorkAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Note: In .NET 6+, &lt;code&gt;CancellationToken&lt;/code&gt; can be injected directly into Action methods. Before that, you’d use &lt;code&gt;HttpContext.RequestAborted&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Harder Than It Looks
&lt;/h2&gt;

&lt;p&gt;Many developers think "I just need to add a timeout." But a timeout on the HTTP client only tells the client when to give up &lt;em&gt;waiting&lt;/em&gt;. It doesn't tell the server to stop &lt;em&gt;working&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;CancellationToken&lt;/code&gt; is a cooperative signal. It tells every layer of your application: "The context is no longer valid. Stop doing work."&lt;/p&gt;

&lt;p&gt;When you fail to propagate this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Resource Leaks:&lt;/strong&gt; Background jobs keep consuming memory and threads.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Stale Data:&lt;/strong&gt; You might update the database after the user has already moved on, causing consistency issues.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Shutdown Hangs:&lt;/strong&gt; If you don't respect the token, your application may hang when trying to shut down gracefully, because it’s waiting for these "zombie" tasks to finish.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Practical Takeaway
&lt;/h2&gt;

&lt;p&gt;Always ask yourself: &lt;strong&gt;Who is allowed to cancel this work?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you are doing &lt;code&gt;fire-and-forget&lt;/code&gt; work, you need a cancellation strategy. If it’s tied to the request, use &lt;code&gt;RequestAborted&lt;/code&gt;. If it’s tied to the application lifecycle, use the &lt;code&gt;CancellationToken&lt;/code&gt; passed to &lt;code&gt;HostedService&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In code review, look for &lt;code&gt;Task.Run&lt;/code&gt; or &lt;code&gt;async&lt;/code&gt; methods that take no parameters. Ask: &lt;em&gt;"What happens if the client disconnects? What happens if the app shuts down?"&lt;/em&gt; If the answer is "it keeps running," you have a bug waiting to happen under load.&lt;/p&gt;

&lt;h1&gt;
  
  
  CSharp #DotNet #Debugging #Architecture
&lt;/h1&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
