Back to blog
Backend Systemsintermediate

Long-Running Operations — 202 Accepted, Polling, and Job Status APIs

Design REST APIs for operations that take seconds or minutes — the 202 Accepted pattern, job status endpoints, Server-Sent Events for progress streaming, and when to use webhooks instead of polling.

LearnixoApril 15, 20266 min read
.NETC#RESTASP.NET CoreAsync202 AcceptedBackground JobsHangfire
Share:𝕏

Some operations can't complete in milliseconds — generating a report, processing a video, running a bulk import. Making clients wait on a synchronous HTTP connection for two minutes is wrong. The REST-native solution is the 202 Accepted pattern with a job status API.


The Problem With Synchronous Long Operations

C#
// ❌ Synchronous — client waits 3 minutes, load balancer times out, request fails
[HttpPost("reports/generate")]
public async Task<IActionResult> GenerateReport(ReportRequest request, CancellationToken ct)
{
    var report = await _reportService.GenerateAsync(request, ct);  // 3 minutes
    return Ok(report);
}

Problems:

  • HTTP timeout (typically 30–120 seconds at the load balancer) kills the connection
  • Client has no way to track progress
  • Server thread blocked for the full duration
  • Client can't cancel and restart safely

The 202 Accepted Pattern

Accept the request, start the job in the background, return immediately with a URL to track progress:

HTTP
POST /api/reports
Content-Type: application/json

{"type": "SalesReport", "from": "2026-01-01", "to": "2026-03-31"}

HTTP/1.1 202 Accepted
Location: /api/jobs/a3f4b2c1-d5e6-f7a8-b1c2-d3e4f5a6b7c8
Content-Type: application/json

{
  "jobId": "a3f4b2c1-d5e6-f7a8-b1c2-d3e4f5a6b7c8",
  "status": "queued",
  "statusUrl": "/api/jobs/a3f4b2c1-d5e6-f7a8-b1c2-d3e4f5a6b7c8",
  "estimatedDuration": "2-3 minutes"
}

The Location header is the RFC 9110 standard way to point to the job status resource.


Implementation: Job Submission

C#
public record ReportRequest(string Type, DateOnly From, DateOnly To);

[HttpPost("reports")]
public async Task<IActionResult> GenerateReport(
    ReportRequest request, CancellationToken ct)
{
    // Create a job record
    var job = new Job
    {
        Id        = Guid.NewGuid(),
        Type      = "ReportGeneration",
        Status    = JobStatus.Queued,
        Payload   = JsonSerializer.Serialize(request),
        CreatedAt = DateTime.UtcNow,
    };

    await _db.Jobs.AddAsync(job, ct);
    await _db.SaveChangesAsync(ct);

    // Enqueue background work (Hangfire, Azure Service Bus, etc.)
    _backgroundJobs.Enqueue<IReportService>(svc =>
        svc.GenerateAsync(job.Id, request));

    var statusUrl = Url.Action(nameof(GetJobStatus), new { id = job.Id });

    return Accepted(statusUrl, new
    {
        jobId     = job.Id,
        status    = "queued",
        statusUrl,
    });
}

Job Status Model

C#
public enum JobStatus { Queued, Running, Completed, Failed, Cancelled }

public class Job
{
    public Guid      Id          { get; set; }
    public string    Type        { get; set; } = default!;
    public JobStatus Status      { get; set; }
    public string?   Payload     { get; set; }
    public string?   ResultUrl   { get; set; }  // where to get the result
    public string?   ErrorMessage { get; set; }
    public int       ProgressPct { get; set; }  // 0–100
    public DateTime  CreatedAt   { get; set; }
    public DateTime? StartedAt   { get; set; }
    public DateTime? CompletedAt { get; set; }
}

Job Status Endpoint (Polling Target)

C#
[HttpGet("jobs/{id:guid}")]
public async Task<IActionResult> GetJobStatus(Guid id, CancellationToken ct)
{
    var job = await _db.Jobs.FindAsync(id, ct);
    if (job is null) return NotFound();

    var response = new
    {
        jobId       = job.Id,
        status      = job.Status.ToString().ToLower(),
        progressPct = job.ProgressPct,
        createdAt   = job.CreatedAt,
        startedAt   = job.StartedAt,
        completedAt = job.CompletedAt,
        resultUrl   = job.Status == JobStatus.Completed ? job.ResultUrl : null,
        error       = job.Status == JobStatus.Failed    ? job.ErrorMessage : null,
    };

    // Tell the client how long to wait before polling again
    return job.Status switch
    {
        JobStatus.Completed => Ok(response),
        JobStatus.Failed    => Ok(response),         // 200 — job completed with failure
        _                   => Accepted(response),   // 202 — still in progress
    };
}

Add a Retry-After header to guide polling interval:

C#
Response.Headers.RetryAfter = "5";  // poll again in 5 seconds
return Accepted(response);

The Background Worker

Using Hangfire:

C#
public class ReportService : IReportService
{
    private readonly AppDbContext _db;
    private readonly IBlobStorage _storage;

    public async Task GenerateAsync(Guid jobId, ReportRequest request)
    {
        // Update status to Running
        var job = await _db.Jobs.FindAsync(jobId);
        job!.Status    = JobStatus.Running;
        job.StartedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();

        try
        {
            // Do the work — update progress periodically
            var report = new ReportBuilder();

            for (int month = 1; month <= 3; month++)
            {
                await report.AddMonthAsync(month);

                job.ProgressPct = month * 33;
                await _db.SaveChangesAsync();
            }

            // Store result
            var resultUrl = await _storage.UploadAsync(
                report.ToStream(), "application/pdf");

            job.Status      = JobStatus.Completed;
            job.ResultUrl   = resultUrl.ToString();
            job.CompletedAt = DateTime.UtcNow;
            job.ProgressPct = 100;
        }
        catch (Exception ex)
        {
            job!.Status       = JobStatus.Failed;
            job.ErrorMessage  = ex.Message;
            job.CompletedAt   = DateTime.UtcNow;
        }

        await _db.SaveChangesAsync();
    }
}

Polling vs Server-Sent Events

Polling works but is inefficient — the client hits /jobs/{id} every 5 seconds even when nothing has changed. Server-Sent Events (SSE) push progress updates to the client over a single long-lived HTTP connection.

C#
[HttpGet("jobs/{id:guid}/progress")]
public async Task StreamProgress(Guid id, CancellationToken ct)
{
    Response.ContentType = "text/event-stream";
    Response.Headers.CacheControl = "no-cache";
    Response.Headers.Connection = "keep-alive";

    while (!ct.IsCancellationRequested)
    {
        var job = await _db.Jobs.AsNoTracking().FirstAsync(j => j.Id == id, ct);

        // Send SSE event
        await Response.WriteAsync(
            $"data: {{\"status\":\"{job.Status}\",\"progress\":{job.ProgressPct}}}\n\n", ct);
        await Response.Body.FlushAsync(ct);

        if (job.Status is JobStatus.Completed or JobStatus.Failed)
            break;

        await Task.Delay(1000, ct);  // push update every second
    }
}

Client-side (browser):

JAVASCRIPT
const source = new EventSource('/api/jobs/a3f4b2c1-.../progress');
source.onmessage = (e) => {
    const { status, progress } = JSON.parse(e.data);
    updateProgressBar(progress);
    if (status === 'completed') source.close();
};

SSE is unidirectional (server → client), works over standard HTTP, reconnects automatically, and is much simpler than WebSockets for progress streaming.


Job Cancellation

Allow clients to cancel jobs they started:

C#
[HttpDelete("jobs/{id:guid}")]
[Authorize]
public async Task<IActionResult> CancelJob(Guid id, CancellationToken ct)
{
    var job = await _db.Jobs.FindAsync(id, ct);
    if (job is null) return NotFound();

    if (job.Status is JobStatus.Completed or JobStatus.Failed)
        return Conflict(new { message = "Job has already finished." });

    job.Status = JobStatus.Cancelled;
    await _db.SaveChangesAsync(ct);

    // Signal the background worker to stop (via CancellationToken or a flag)
    _backgroundJobs.Delete(job.HangfireJobId);

    return NoContent();
}

Webhooks Instead of Polling

For integrations where the client is another service, polling is wasteful. Use webhooks — the server calls the client when done:

C#
public record JobRequest(string Type, string? WebhookUrl = null);

// When the job completes:
if (!string.IsNullOrEmpty(job.WebhookUrl))
{
    var payload = new { jobId = job.Id, status = "completed", resultUrl = job.ResultUrl };
    await _httpClient.PostAsJsonAsync(job.WebhookUrl, payload);
}

Client registers a webhook URL with the job request:

JSON
{
  "type": "SalesReport",
  "webhookUrl": "https://client.example.com/hooks/report-complete"
}

When the job finishes, your API POSTs the result to the client. No polling, no persistent connection.


Complete Flow Summary

1. POST /api/reports
   → 202 Accepted
   → Location: /api/jobs/{jobId}
   → Background job started

2. GET /api/jobs/{jobId}      (polling, every Retry-After seconds)
   → 202 { status: "running", progress: 45 }
   → 202 { status: "running", progress: 80 }
   → 200 { status: "completed", resultUrl: "/api/reports/{reportId}" }

-- OR --

2. GET /api/jobs/{jobId}/progress    (SSE streaming)
   → Server pushes updates over single connection
   → Stream closes when job finishes

3. GET /api/reports/{reportId}
   → 200 with the completed report

-- OR (webhook) --

2. POST to webhookUrl when complete
   → Client receives notification, fetches result

Quick Reference

202 Accepted         → request accepted, job queued, not yet complete
Location header      → URL of the job status resource
Retry-After header   → tell client how long to wait before polling
Job statuses:        queued → running → completed | failed | cancelled
Progress streaming:  Server-Sent Events (text/event-stream)
Webhooks:            POST to client URL on completion — no polling needed

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.