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.
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
// ❌ 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:
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
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
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)
[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:
Response.Headers.RetryAfter = "5"; // poll again in 5 seconds
return Accepted(response);The Background Worker
Using Hangfire:
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.
[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):
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:
[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:
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:
{
"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 resultQuick 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 neededEnjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.