• Blog

Mastering Long-Running Tasks in ASP.NET Core Without Blocking Requests

How to Handle Long-Running Tasks in ASP.NET Core

Table of Contents

Sign Docs 3x Faster

Send, sign, and manage documents securely and efficiently.

Summarize the blog post with:

TL;DR: Don’t make users wait while heavy work runs. Acknowledge the request right away, finish the work in the background, and let them check progress (or notify them) when it’s done.

Long-running tasks in ASP.NET Core like generating reports, processing files, or calling slow third-party services shouldn’t run inside controller actions.
Keeping an HTTP request open while you do heavy work causes real problems:

  • Timeouts (clients, reverse proxies, load balancers)
  • Lower throughput (requests occupy resources longer)
  • Unreliable execution (deployments/restarts kill in-flight work)
  • Retry amplification (timeouts trigger retries → more load → more timeouts)

A better pattern is simple:

  1. Accept the request quickly
  2. Enqueue the work
  3. Return 202 Accepted with a job ID
  4. Process the work in the background
  5. Let the client check status (or receive a webhook callback)

What counts as a “long-running task” in ASP.NET Core

A “long-running task” is any work that’s long enough to risk timeouts, tie up resources, or get interrupted by restarts.
Common examples:

  • Bulk email sending
  • PDF/Excel report generation
  • File post-processing (virus scan, thumbnails, transcodes)
  • Video/image processing
  • Slow third-party API calls (payments, CRM sync, KYC)
  • Webhook retries
  • Scheduled callbacks

A practical rule: if the work isn’t required to build the immediate HTTP response, it probably shouldn’t run in the controller.

How does the “Enqueue + 202 Accepted” pattern work

Here’s the backbone pattern you can reuse with BackgroundService, Hangfire, or a message broker.

Api controller diagram

Bad: Controller → Long work → Client waits 
Good: Controller → Queue → Worker → Client moves on 

“Fire-and-forget” in ASP.NET Core: When it’s OK (and when it’s not) 

Best-effort fire-and-forget is only acceptable when you can tolerate lost work. 
Reason: if the process crashes or restarts, in-memory work disappears. Also, exceptions can be missed if you don’t observe them. 

OK for (best-effort): 

  • Non-critical telemetry enrichment 
  • Optional cache warmups 
  • Low-risk tasks you’re fine skipping 

Not OK for: 

  • Anything that must happen (billing, compliance workflows) 
  • Emails users expect 
  • Reports users rely on 
  • Data sync that must be consistent 

If you need reliability, use a queue or job system. 

Choosing the right approach: Quick decision guide 

Pick the simplest option that meets your durability and scale needs. 

Use case Recommended approach 
Best-effort background work (small, non-critical) BackgroundService 
Background work with backpressure inside the API BackgroundService + in-memory queue (Channel) 
CPU-heavy work Separate worker service/process 
Jobs must survive restarts Durable queue (RabbitMQ/SQS/Service Bus) + worker 
Scheduling/recurring jobs Hangfire or Quartz.NET 
Need dashboard + retries + persistence quickly Hangfire 

Hosted services basics: IHostedService vs BackgroundService 

ASP.NET Core “hosted services” are the built-in way to run background logic alongside your app. 

  • IHostedService is the low-level interface (StartAsync / StopAsync). 
  • BackgroundService is a helper base class that gives you ExecuteAsync(CancellationToken) for long-running loops and handles start/stop glue for you. 

If you’re building a worker loop, BackgroundService is usually the easiest starting point. 

A safe BackgroundService pattern (bounded work + cancellation) 

Here’s a basic worker that: 

  • Runs a loop 
  • Honors shutdown via CancellationToken 
  • Avoids a “tight failure loop” with a small delay

using Microsoft.Extensions.Hosting; 
using Microsoft.Extensions.Logging; 
public sealed class ExampleWorker : BackgroundService 
{ 
    private readonly ILogger _logger;
    public ExampleWorker(ILogger logger) => _logger = logger; 
    protected override async Task ExecuteAsync(CancellationToken stoppingToken) 
    { 
        _logger.LogInformation("Worker started"); 
        while (!stoppingToken.IsCancellationRequested) 
        { 
            try 
            { 
                // Do one bounded unit of work per loop iteration 
                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); 
                _logger.LogInformation("Worker heartbeat at {Time}", DateTimeOffset.UtcNow); 
            } 
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) 
            {
                // graceful shutdown 
            } 
            catch (Exception ex) 
            { 
                _logger.LogError(ex, "Unhandled exception in worker loop"); 
                await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); 
            } 
        } 
        _logger.LogInformation("Worker stopping"); 
    } 
} 
Register it: 
builder.Services.AddHostedService(); 
    

If your goal is “don’t block HTTP requests,” the cleanest approach is producer–consumer:

  • Controllers produce work by enqueuing jobs
  • A worker consumes jobs and executes them

For an in-process queue, System.Threading.Channels is a great fit: it’s async-friendly, fast, and supports bounded capacity (backpressure).

Step 1: Define a job contract 

Keep the message small. Pass IDs, not big payloads (store large data in DB/blob storage).


public sealed record WorkItem( 
    string JobId, 
    Func Handler 
); 
    

Step 2: Create a bounded Channel-based queue 

Bounded capacity prevents your API from “accepting infinite work” during spikes. 


using System.Threading.Channels; 
public interface IBackgroundTaskQueue 
{ 
    ValueTask EnqueueAsync(WorkItem item, CancellationToken ct = default); 
    ValueTask DequeueAsync(CancellationToken ct); 
} 
public sealed class ChannelBackgroundTaskQueue : IBackgroundTaskQueue 
{ 
    private readonly Channel _channel; 
    public ChannelBackgroundTaskQueue(int capacity = 1000) 
    { 
        _channel = Channel.CreateBounded( 
            new BoundedChannelOptions(capacity) 
            { 
                FullMode = BoundedChannelFullMode.Wait 
            }); 
    }  
    public ValueTask EnqueueAsync(WorkItem item, CancellationToken ct = default) 
        => _channel.Writer.WriteAsync(item, ct); 
    public ValueTask DequeueAsync(CancellationToken ct) 
        => _channel.Reader.ReadAsync(ct); 
} 
    

Step 3: Build a worker that consumes the queue 

This worker stops cleanly during shutdown and logs job outcomes. 


using System.Threading.Channels; 
public interface IBackgroundTaskQueue 
{ 
    ValueTask EnqueueAsync(WorkItem item, CancellationToken ct = default); 
    ValueTask DequeueAsync(CancellationToken ct); 
} 
public sealed class ChannelBackgroundTaskQueue : IBackgroundTaskQueue 
{ 
    private readonly Channel _channel; 
    public ChannelBackgroundTaskQueue(int capacity = 1000) 
    { 
        _channel = Channel.CreateBounded( 
            new BoundedChannelOptions(capacity) 
            { 
                FullMode = BoundedChannelFullMode.Wait 
            }); 
    } 
    public ValueTask EnqueueAsync(WorkItem item, CancellationToken ct = default) 
        => _channel.Writer.WriteAsync(item, ct); 
    public ValueTask DequeueAsync(CancellationToken ct) 
        => _channel.Reader.ReadAsync(ct); 
}  
    

Step 4: Enqueue from a controller and return 202 Accepted 

The controller stays fast. The background worker does the heavy lifting. 


using Microsoft.AspNetCore.Mvc; 
[ApiController] 
[Route("api/reports")] 
public sealed class ReportsController : ControllerBase 
{ 
    private readonly IBackgroundTaskQueue _queue; 
    public ReportsController(IBackgroundTaskQueue queue) => _queue = queue; 
    [HttpPost("{reportId}:generate")] 
    public async Task Generate(string reportId, CancellationToken ct) 
    { 
        var jobId = Guid.NewGuid().ToString("n"); 
        await _queue.EnqueueAsync( 
            new WorkItem(jobId, async token => 
            { 
                // Example: generate report, store it, update job status in DB 
                await Task.Delay(TimeSpan.FromSeconds(10), token); 
            }), 
            ct); 
        return Accepted(new { jobId, statusUrl = $"/api/jobs/{jobId}" }); 
    } 
} 
    

Step 5: Wire everything up in DI 


builder.Services.AddSingleton(_ => 
    new ChannelBackgroundTaskQueue(capacity: 500)); 
builder.Services.AddHostedService();     

Job status: Make 202 accepted actually useful 

Returning 202 is only half the story. Clients need a way to check progress. 

A practical contract: 

  • POST /reports/{id}:generate → 202 Accepted with { jobId, statusUrl } 
  • GET /jobs/{jobId} → queued | running | completed | failed (+ output link when ready) 
  • Optional: webhook callback for async clients 

Minimal job status model (example) 


public enum JobState { Queued, Running, Completed, Failed } 
public sealed record JobStatus( 
    string JobId, 
    JobState State, 
    string? ResultUrl = null, 
    string? Error = null 
);
    
Tip: Store job state in a durable store (DB/Redis) even if the queue is in-memory. That gives you visibility and debuggability right away. 

When in-memory queues aren’t enough: Durability across restarts 

An in-memory queue is great for getting started, but it has a hard limit: 

  • If the process dies, queued work is gone 
  • If you scale down, in-flight work can be lost 
  • If you deploy, work might be interrupted 

When the work must survive restarts, move the job storage outside the web process:

  • Message brokers: Azure Service Bus, RabbitMQ, AWS SQS 
  • Job systems: Hangfire (persistent jobs + retries + dashboard) 
  • Separate worker services: clean separation, independent scaling 

How do you consume jobs using Azure Service Bus in ASP.NET Core

This shows the general “durable queue + worker” pattern. Production code should also handle idempotency, poison messages, and backoff. 


using Azure.Messaging.ServiceBus; 
using Microsoft.Extensions.Hosting; 
using Microsoft.Extensions.Logging; 
public sealed class ServiceBusWorker : BackgroundService 
{ 
    private readonly ServiceBusProcessor _processor; 
    private readonly ILogger _logger; 
    public ServiceBusWorker(ServiceBusClient client, ILogger logger) 
    { 
        _logger = logger; 
        _processor = client.CreateProcessor("report-jobs", new ServiceBusProcessorOptions 
        { 
            MaxConcurrentCalls = 4, 
           AutoCompleteMessages = false 
        }); 
        _processor.ProcessMessageAsync += OnMessageAsync; 
        _processor.ProcessErrorAsync += OnErrorAsync; 
    }  
    protected override async Task ExecuteAsync(CancellationToken stoppingToken) 
    { 
        await _processor.StartProcessingAsync(stoppingToken); 
        await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); 
    } 
    public override async Task StopAsync(CancellationToken cancellationToken) 
    { 
        await _processor.StopProcessingAsync(cancellationToken); 
        await _processor.DisposeAsync(); 
        await base.StopAsync(cancellationToken); 
    } 
    private async Task OnMessageAsync(ProcessMessageEventArgs args) 
    { 
         var body = args.Message.Body.ToString(); 
        _logger.LogInformation("Received job: {Body}", body); 
        try 
        { 
            // Do work (make it idempotent) 
            await Task.Delay(TimeSpan.FromSeconds(5), args.CancellationToken);
            await args.CompleteMessageAsync(args.Message, args.CancellationToken); 
        } 
        catch (Exception ex) 
        { 
            _logger.LogError(ex, "Job failed; abandoning message"); 
            await args.AbandonMessageAsync(args.Message, cancellationToken: args.CancellationToken); 
        } 
    } 
    private Task OnErrorAsync(ProcessErrorEventArgs args) 
    { 
        _logger.LogError(args.Exception, "Service Bus error: {Entity}", args.EntityPath); 
        return Task.CompletedTask; 
    } 
} 
    

Hangfirevs BackgroundService: When to pick Hangfire

Use Hangfire when you want: 

  • Durable, persisted jobs 
  • Automatic retries 
  • Delayed and recurring jobs 
  • A dashboard for job visibility 
  • Less custom plumbing 

Example: Enqueue a Hangfire job from an API 


using Hangfire; 
using Microsoft.AspNetCore.Mvc; 
[ApiController] 
[Route("api/emails")] 
public sealed class EmailsController : ControllerBase 
{ 
    [HttpPost("send")] 
    public IActionResult SendEmail([FromBody] SendEmailRequest request) 
    { 
        var jobId = BackgroundJob.Enqueue(() => 
            EmailJobs.SendAsync(request.To, request.Subject, request.Body)) 
        return Accepted(new { jobId }); 
    }
} 
public sealed record SendEmailRequest(string To, string Subject, string Body); 
public static class EmailJobs 
{ 
    public static Task SendAsync(string to, string subject, string body) 
    { 
        // real email sender here 
        return Task.CompletedTask; 
    } 
}  
    

When Quartz.NET makes more sense 

Quartz.NET shines when your main need is scheduling, not processing a backlog. 

Use Quartz when: 

  • You need cron-style schedules 
  • Jobs are time-based and predictable 
  • You don’t need queue semantics as the core model 

If you need both scheduling and durable background jobs with retries and visibility, many teams choose Hangfire. 

Production essentials for background work 

Background processing isn’t just “run code later.” In production, these concerns matter most: 

1) Graceful shutdown 

  • Always honor CancellationToken 
  • Stop accepting new work during shutdown 
  • Make sure workers exit cleanly 

2) Idempotency (so retries don’t double-do work) 

Retries happen. Design for it: 

  • Use a jobId / requestId as an idempotency key 
  • Store state transitions (Queued → Running → Completed/Failed) 
  • Make handlers safe to run twice (or detect duplicates) 

3) Backpressure and load control 

  • Prefer bounded queues 
  • Limit concurrency based on CPU/IO 
  • Add backoff to avoid rapid retry storms 

4) Observability (you can’t operate what you can’t see) 

At minimum, track: 

  • Enqueue rate 
  • Queue depth 
  • Job duration (queue time + processing time) 
  • Success/failure counts 
  • Retry and dead-letter counts (for brokers) 

Common mistakes to avoid 

  1. Using Task.Run() in controllers for important work (work can be lost, failures can be missed) 
  2. Blocking async code with .Result / .Wait() (thread pool starvation under load) 
  3. Unbounded queues (memory pressure and cascading failures) 
  4. No idempotency (retries cause duplicates) 
  5. No status endpoint (clients retry blindly)
  6. CPU-heavy jobs inside the web process (hurts p99 latency for unrelated requests) 

Summary: The safest path for long-running tasks in ASP.NET Core 

If you remember one thing: keep requests short, and make background work durable when it matters. 

  • Return quickly (often 202 Accepted) instead of holding connections open. 
  • Start with BackgroundService + Channel for simple in-process work. 
  • Move to a durable queue/job system when jobs can’t be lost. 
  • Design for idempotency, retries, shutdown safety, and visibility. 

Practical checklist 

Use this before shipping background processing to production: 

  • Controller returns quickly (no long work in request pipeline) 
  • Work is enqueued and processed by a background worker 
  • Queue is bounded (backpressure) 
  • Every job has a jobId (idempotency key) 
  • Job status is stored and queryable (/jobs/{jobId}) 
  • Worker honors CancellationToken and stops cleanly 
  • Retries have caps and backoff (and dead-letter/poison handling if durable) 
  • Side effects are idempotent (no double emails/charges) 
  • Metrics/logging exist for queue depth, latency, duration, failures 
  • CPU-heavy work runs outside the web process (if needed) 

Start today and unlock all features of BoldSign.

Need assistance? Request a demo or visit our Support Portal for quick help.

Build non‑blocking, high‑performance workloads and integrate smooth, secure eSignature workflows with BoldSign’s API

FAQ 

What’s the best way to run long-running tasks in ASP.NET Core? 

Use a background worker pattern: enqueue work in the controller, return quickly (often 202 Accepted), and process via BackgroundService or a durable queue/job system. 


Is it okay to use Task.Run inside an ASP.NET Core controller? 

It’s risky for anything important. Work can be lost on restarts, failures are easy to miss, and you can contribute to thread pool starvation under load. 


What’s the difference between IHostedService and BackgroundService? 

IHostedService is the interface with StartAsync/StopAsync. BackgroundService is a helper base class that provides ExecuteAsync(CancellationToken) for long-running loops. 


When should I use a message queue instead of BackgroundService? 

When jobs must survive process restarts, need load leveling, or must scale independently from the API. 


How do I avoid duplicate processing when retries happen? 

Use a jobId/idempotency key, store job state transitions, and make handlers safe to run twice (or detect duplicates). 


Should I use Hangfire or Quartz.NET? 

Use Hangfire for durable jobs with retries and dashboards. Use Quartz.NET primarily for time-based scheduling (cron-style) when a backlog/queue model isn’t the main need. 

Like what you see? Share with a friend.

Latest blog posts

Why SOC 2 Certification Matters for E-Signature Platforms

Why SOC 2 Certification Matters for E-Signature Platforms

Learn how SOC 2 certification strengthens e-signature security. Understand scope, audit periods, Type I vs. Type II reports, and what customers should verify.

Introducing BoldSign’s Twitter QR Code Generator

Introducing BoldSign’s Twitter QR Code Generator

Generate Twitter QR code for your profile or tweet instantly. Free, no watermark, fully customizable, and ready to download in high-quality PNG or SVG.

Stop Chasing Signatures: BoldSign Is Now on the HubSpot Marketplace

Stop Chasing Signatures: BoldSign Is Now on the HubSpot Marketplace

BoldSign is now on the HubSpot Marketplace. Send, track, and store eSignatures in HubSpot to reduce delays, cut admin work, and close faster.

Sign up for your free trial today!

  • tick-icon
    30-day free trial
  • tick-icon
    No credit card required
  • tick-icon
    30-day free trial
  • tick-icon
    No credit card required
Sign up for BoldSign free trial