WebAPI 2
asynchronous programming
long running tasks
multithreading
client response

webapi 2 - how to properly invoke long running method async/in new thread, and return response to client

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

If a Web API 2 request starts work that may take seconds or minutes, the controller should normally return quickly instead of keeping the HTTP connection open. Starting a raw thread inside the controller is usually the wrong solution because the IIS-hosted process can recycle, the thread is not durable, and failures are hard to observe.

The production-safe pattern is to accept the request, enqueue background work, return 202 Accepted, and give the client a job identifier or status URL. That makes the API responsive and the work lifecycle explicit.

Why Raw Threads in Controllers Are a Bad Fit

A controller method runs inside a hosted web process with its own lifecycle. If you call new Thread(...) or Task.Run(...) and return immediately, you have created background work that the web host does not really manage as durable application state.

That leads to familiar problems:

  • app recycle can kill the work
  • exceptions may disappear into logs or nowhere at all
  • there is no retry or persistence boundary
  • clients do not know how to track completion

A controller should validate input and schedule work, not own long-running background threads.

Return 202 Accepted With a Job ID

A better contract is to create a background job record and return immediately.

csharp
1using System;
2using System.Net;
3using System.Threading.Tasks;
4using System.Web.Http;
5
6[RoutePrefix("api/reports")]
7public class ReportsController : ApiController
8{
9    private readonly IJobQueue _queue;
10    private readonly IJobStore _store;
11
12    public ReportsController(IJobQueue queue, IJobStore store)
13    {
14        _queue = queue;
15        _store = store;
16    }
17
18    [HttpPost]
19    [Route("generate")]
20    public async Task<IHttpActionResult> GenerateAsync(ReportRequest request)
21    {
22        if (request == null) return BadRequest("Request payload is required.");
23
24        var jobId = Guid.NewGuid().ToString("N");
25        await _store.CreateAsync(jobId, "queued");
26        await _queue.EnqueueAsync(new ReportJob(jobId, request));
27
28        var statusUrl = Url.Link("GetReportStatus", new { id = jobId });
29        return Content(HttpStatusCode.Accepted, new { jobId, status = "queued", statusUrl });
30    }
31}

Now the API has a stable contract: accepted for processing, not finished yet.

Add Status Endpoints

Clients need a way to ask what happened to the job.

csharp
1[HttpGet]
2[Route("jobs/{id}", Name = "GetReportStatus")]
3public async Task<IHttpActionResult> GetStatusAsync(string id)
4{
5    var state = await _store.GetAsync(id);
6    if (state == null) return NotFound();
7    return Ok(state);
8}
9
10[HttpGet]
11[Route("jobs/{id}/result")]
12public async Task<IHttpActionResult> GetResultAsync(string id)
13{
14    var job = await _store.GetAsync(id);
15    if (job == null) return NotFound();
16    if (job.Status != "succeeded") return Content(HttpStatusCode.Conflict, job);
17
18    return Ok(new { jobId = id, fileUrl = job.OutputUrl });
19}

This design is much easier for browsers, mobile clients, and operations teams to reason about than “fire a thread and hope it finishes.”

Let a Worker Own the Long-Running Work

The background worker should update job state, capture errors, and handle retries.

csharp
1public class ReportJobHandler : IJobHandler<ReportJob>
2{
3    private readonly IReportService _service;
4    private readonly IJobStore _store;
5
6    public ReportJobHandler(IReportService service, IJobStore store)
7    {
8        _service = service;
9        _store = store;
10    }
11
12    public async Task HandleAsync(ReportJob job)
13    {
14        await _store.UpdateAsync(job.JobId, "running");
15        try
16        {
17            var outputUrl = await _service.GenerateAsync(job.Request);
18            await _store.UpdateAsync(job.JobId, "succeeded", outputUrl: outputUrl);
19        }
20        catch (Exception ex)
21        {
22            await _store.UpdateAsync(job.JobId, "failed", error: ex.Message);
23            throw;
24        }
25    }
26}

This is the right place for retries, metrics, and job-level logging.

Idempotency Matters

If the client times out and retries the request, you do not want duplicate long-running jobs unless that is explicitly allowed.

csharp
1public async Task<string> CreateOrReuseAsync(string requestKey, ReportRequest request)
2{
3    var existing = await _store.FindByRequestKeyAsync(requestKey);
4    if (existing != null) return existing.JobId;
5
6    var jobId = Guid.NewGuid().ToString("N");
7    await _store.CreateAsync(jobId, "queued", requestKey);
8    return jobId;
9}

Idempotency turns retries from a production problem into an expected control flow.

Common Pitfalls

A common mistake is thinking Task.Run inside a controller makes the work “background-safe.” It only moves work to another thread in the same fragile host process.

Another issue is returning 200 OK immediately without giving the client any way to learn whether the long-running operation later succeeded or failed.

Developers also sometimes skip durable job-state storage. Without it, status endpoints, retries, and operational visibility all become guesswork.

Finally, do not make the API pretend a background operation is synchronous when the user experience is actually asynchronous.

Summary

  • Do not start unmanaged long-running threads inside Web API 2 controllers.
  • Accept the request quickly, enqueue the work, and return 202 Accepted.
  • Give clients a job ID and status endpoint.
  • Let a durable background worker own execution, retries, and failure tracking.
  • Add idempotency so client retries do not create duplicate jobs.

Course illustration
Course illustration

All Rights Reserved.