Let's be real: we've all written code that we knew was a ticking time bomb. For me, it was a reporting dashboard I built a few years ago. I thought I was being clever by utilizing async/await everywhere, assuming it freed up the threads. But when 100 users simultaneously requested a PDF report exporting a full year of transactions, the server didn't just slow down—it completely choked. Thread pool starvation hit, TCP sockets exhausted, and the application container crashed. It was a classic production nightmare.

The core issue is that even though await yields the worker thread back to Kestrel, the client connection is still held open. You're holding onto TCP connections, IIS application pools, and memory. If a client closes the tab because it's taking too long, the server keeps executing the heavy database queries anyway because nobody checks cancellation tokens.

In this article, we'll look at how to get these long-running tasks off the HTTP pipeline using .NET's built-in System.Threading.Channels, and the exact traps (like Scoped service lifetimes in Singletons) that will crash your app on startup.

Prerequisites

This guide assumes you are familiar with C#, ASP.NET Core dependency injection, and Async/Await patterns.

Why You Shouldn't `await` Long Tasks Inline

Every HTTP request in ASP.NET Core is handled by Kestrel. When a request hits, Kestrel allocates a thread from the thread pool. Even if you use async/await to make the call non-blocking for CPU threads, the client browser is still hanging on the wire, keeping the TCP connection active.

Under a sudden traffic spike, your thread pool gets saturated. You'll run out of available TCP sockets or hit Kestrel limits, causing the server to throw 504 Gateway Timeouts. Plus, if a user gets annoyed, closes their browser tab, and walks away, the server keeps executing the heavy database queries anyway because there's no natural way to abort the task unless you perfectly passed and monitored a CancellationToken.

The Anti-Pattern: Awaiting Inline

Here is the classic pitfall to avoid:

C# Controller
[HttpPost("generate-report")]
public async Task GenerateReport()
{
  // BAD: Blocks the request socket for 10+ seconds while doing heavy PDF generation
  await _reportService.GenerateHeavyPdfAsync(); 
  
  return Ok("Report generated"); 
}

Solution: Queued Background Tasks

Instead of doing the work inline, the API controller should do two things: drop a small job payload into a queue, and immediately return a 202 Accepted status to the client, along with a task ID or status URL.

A background worker consumes the queue asynchronously. Here are the three main ways to handle this:

Approach Complexity Reliability Best For
Task.Run (Fire & Forget) Low Horrible Avoid in production. If the app pool recycles, the task dies mid-flight.
BackgroundService (Channels) Medium High (In-Memory) Single-instance apps, non-critical processing.
External Queue (RabbitMQ/Hangfire) High Very High Distributed microservices, mission-critical financial tasks.

Implementing `Channel` (And Two Crucial Gotchas)

For single-instance apps, System.Threading.Channels is incredibly performant and thread-safe. We use a bounded channel because an unbounded one will grow indefinitely under load, eating up RAM until the OS kills the process.

C# Service
public class BackgroundTaskQueue
  {
      private readonly Channel> _queue;

      public BackgroundTaskQueue(int capacity)
      {
          // BoundedChannelFullMode.Wait blocks the writer when the queue is full
          var options = new BoundedChannelOptions(capacity)
          {
              FullMode = BoundedChannelFullMode.Wait
          };
          _queue = Channel.CreateBounded>(options);
      }

      public async ValueTask QueueBackgroundWorkItemAsync(
          Func workItem)
      {
          if (workItem == null) throw new ArgumentNullException(nameof(workItem));
          await _queue.Writer.WriteAsync(workItem);
      }

      public async ValueTask> DequeueAsync(
          CancellationToken cancellationToken)
      {
          return await _queue.Reader.ReadAsync(cancellationToken);
      }
  }

Gotcha #1: The Scoped Service Trap

This is the most common mistake I see developers make when setting up a BackgroundService. Your background worker is registered as a Singleton. But database contexts (like Entity Framework's DbContext) are registered as Scoped. If you try to inject a Scoped service directly into the constructor of your Singleton BackgroundService, .NET will throw a dependency injection validation error on startup, preventing the host from even running.

If you disable validation (please don't), you'll end up sharing a single DbContext instance across multiple threads, causing database corruption and concurrency crashes. To avoid this, you must inject IServiceScopeFactory, create a scope manually inside your worker loop, and resolve the database context inside that scope:

// Inside your BackgroundService loop
using (var scope = _serviceScopeFactory.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService();
    await dbContext.ProcessReportAsync(taskId);
}

Gotcha #2: The In-Memory Queue Amnesia

In-memory queues like System.Threading.Channels are fast and require zero external dependencies, but they have one massive flaw: they have amnesia. If you deploy a new container to Kubernetes, restart your Docker container, or if IIS decides to recycle your application pool, everything currently sitting in the channel is wiped out.

If you are handling non-critical tasks like updating an activity log or pre-heating a cache, that's fine. But if you are processing credit card payments, sending invoice emails, or handling critical business transactions, do NOT use this in-memory channel. You need a durable, out-of-process queue like RabbitMQ, or a database-backed solution like Hangfire.

Got Questions? I’ve Got Answers

If you use BoundedChannelFullMode.Wait, the thread calling the API will await asynchronously until the background worker processes an item and frees up a slot. This applies a natural "backpressure" to your API, preventing it from consuming all server memory. Alternatively, you can configure it to drop the oldest or newest items if you don't mind losing data under extreme spikes.

Absolutely. System.Threading.Channels is thread-safe and designed for multiple readers and writers by default. You can register three or four instances of your consumer hosted service in your Program.cs, and they will pull jobs off the channel concurrently without race conditions. It's a great way to handle CPU-bound tasks like image manipulation on a single server.

When the host receives a shutdown signal (like a sigterm during deploy), it passes a CancellationToken to your background services. You must design your tasks to respect this token. If they detect a cancel request, they should finish the current database transaction or save state immediately, rather than abruptly shutting down mid-process.

Ashish Kohli

Ashish Kohli

Ashish Kohli is a senior backend engineer and systems architect specializing in cloud infrastructure and high-throughput .NET systems. With a passion for clean architecture, he spends his time designing distributed pipelines and optimizing database query performance.

Read more articles by this author