Not every API operation can finish in a few milliseconds. Generating a large report, processing thousands of records, converting a video, importing customer data, or running a complex analytics job may take several minutes. Keeping an HTTP connection open throughout that process creates unnecessary timeouts, consumes resources, and makes failure recovery difficult.
ASP.NET Core provides a better approach: accept the request, return HTTP 202 Accepted, process the work independently, and let the client track its progress. In this guide, we’ll build that pattern using Minimal APIs, background workers, durable job storage, status endpoints, cancellation, idempotency, and production-ready failure handling.
The Problem With Long-Running HTTP Requests
Imagine an ASP.NET Core application that generates detailed financial reports.
A customer selects a date range and clicks Generate Report.
The application must retrieve millions of records, calculate totals, generate a PDF, and store the finished document.
For a small report, this might take two seconds.
For a large report, it could take several minutes.
A straightforward implementation might look like this:
app.MapPost("/api/reports", async (
ReportRequest request,
ReportService service,
CancellationToken cancellationToken) =>
{
var report = await service.GenerateAsync(
request,
cancellationToken);
return Results.Ok(report);
});The code is asynchronous, but the HTTP request still waits for the entire report to finish.
That distinction matters.
The await keyword allows the server to avoid blocking a thread during supported asynchronous operations. It does not make the operation complete sooner, and it does not eliminate the HTTP request’s lifetime.
The client must remain connected while the work continues.
A reverse proxy may impose a timeout. The user’s network connection may fail. A mobile application may move into the background. The browser may close.
Even if the server can technically keep the request open, the user experience becomes fragile.
A better design separates accepting the request from completing the work.
Asynchronous Code and Asynchronous APIs Are Different
This is one of the most important distinctions in this article.
Consider:
var result = await service.ProcessAsync();This is asynchronous programming.
The application can release the current thread while waiting for asynchronous I/O.
But the caller still waits for the result.
Now consider:
POST /api/reportsResponse:
HTTP/1.1 202 Accepted
Location: /api/operations/8f42a1
Retry-After: 5The server accepts the request and returns immediately with information about how to check its progress.
The actual report generation happens independently.
This is an asynchronous API workflow.
The two approaches solve different problems.
Asynchronous programming improves how application code handles waiting.
Asynchronous API design changes how clients interact with operations that cannot finish promptly.
A production application often needs both.
Understanding HTTP 202 Accepted
HTTP 202 Accepted tells the client that the server has accepted a request for processing, but the requested operation has not necessarily completed.
The response does not promise that processing will eventually succeed.
The operation may later fail, be rejected by downstream processing, or be cancelled.
That makes HTTP 202 different from a normal success response.
For example:
HTTP/1.1 200 OKusually indicates that the request has succeeded and the response represents its result.
HTTP/1.1 201 Createdindicates that the request has resulted in the creation of a resource.
HTTP/1.1 202 Acceptedindicates that processing has been accepted but remains incomplete.
HTTP itself does not provide a mechanism for sending a later final status code for the original request.
The application therefore needs another way to communicate the outcome.
The usual solution is an operation-status resource.
The 202 Accepted Pattern
A typical long-running API workflow looks like this:
The HTTP request finishes quickly while the operation continues independently.
The important architectural decision is that the client does not need to remain connected while the job runs.
Once the operation is accepted, the client can close the application, disconnect, or return later.
The server maintains the operation’s state independently.
This is particularly useful for reports, imports, exports, video processing, bulk updates, machine-learning inference jobs, and other resource-intensive tasks.
Designing the API Contract First
In our previous article, Contract-First API Development in ASP.NET Core, we explored why an API contract should be defined before implementation.
That principle becomes especially valuable with asynchronous APIs because the contract describes an entire workflow rather than a single request and response.
For our report-generation example, we’ll use three endpoints:
Endpoint Purpose
POST /api/reports Submit a report-generation request
GET /api/operations/{id} Check operation status
GET /api/reports/{id}/download Retrieve a completed reportA production API may also expose a cancellation endpoint, which we’ll discuss later.
The client needs to understand what an accepted response contains, which operation states are possible, how often to poll, and where to retrieve the final result.
Those details belong in the API contract.
Returning a Useful 202 Response
A bare HTTP 202 response is valid, but it leaves the client guessing about what happens next.
A more useful response includes an operation identifier and a status URL.
For example:
HTTP/1.1 202 Accepted
Location: /api/operations/8f42a1
Retry-After: 5
Content-Type: application/json{
"operationId": "8f42a1",
"status": "queued",
"statusUrl": "/api/operations/8f42a1"
}The Location header identifies the resource the client should query.
The Retry-After header provides a polling suggestion. Clients should not assume it is a guaranteed completion time.
The response body gives clients the same information in a convenient structured format.
This makes the API self-describing.
The client knows that the operation exists, where to check its status, and that processing is still pending.
Designing Operation States
Long-running operations need an explicit lifecycle.
A useful model is:
Queued
↓
Running
├── Completed
├── Failed
└── CancelledEach state has a specific meaning.
Queued means the request has been accepted but processing has not started.
Running means a worker has claimed the operation and is processing it.
Completed means the work finished successfully and its result is available.
Failed means processing could not complete successfully.
Cancelled means the operation was stopped through an accepted cancellation request or policy.
Some applications also need states such as retrying, cancellation_requested, or expired.
Avoid adding states simply because they might be useful someday.
The lifecycle should represent meaningful differences that clients and operators need to understand.
Building a Minimal ASP.NET Core Implementation
Let’s start with a small example using ASP.NET Core Minimal APIs.
We’ll define the report request and operation response models.
public record ReportRequest(
DateOnly StartDate,
DateOnly EndDate);
public record OperationStatusResponse(
Guid OperationId,
string Status,
string? ResultUrl,
string? Error);For this first example, we’ll use an in-memory dictionary to hold operation state.
This keeps the mechanics easy to understand.
It is not suitable for reliable production processing because operation records disappear when the process restarts.
We’ll address durable storage shortly.
First, define the operation model:
public sealed class OperationRecord
{
public Guid Id { get; init; }
public string Status { get; set; } = "queued";
public string? ResultUrl { get; set; }
public string? Error { get; set; }
}Now create an operation store:
using System.Collections.Concurrent;
public sealed class InMemoryOperationStore
{
private readonly ConcurrentDictionary<Guid, OperationRecord>
_operations = new();
public void Add(OperationRecord operation)
{
_operations[operation.Id] = operation;
}
public OperationRecord? Get(Guid id)
{
return _operations.GetValueOrDefault(id);
}
}ConcurrentDictionary makes dictionary operations thread-safe, but it does not make arbitrary changes to an OperationRecord atomic.
For a production implementation, state transitions should be handled through controlled store operations rather than unrestricted property mutation.
Accepting the Request
Next, create an endpoint that accepts report-generation requests.
app.MapPost("/api/reports", (
ReportRequest request,
InMemoryOperationStore store,
Channel<Guid> queue) =>
{
if (request.StartDate > request.EndDate)
{
return Results.BadRequest(
"Start date must not be after end date.");
}
var operation = new OperationRecord
{
Id = Guid.NewGuid()
};
store.Add(operation);
if (!queue.Writer.TryWrite(operation.Id))
{
return Results.StatusCode(
StatusCodes.Status503ServiceUnavailable);
}
var statusUrl =
$"/api/operations/{operation.Id}";
return Results.Accepted(
statusUrl,
new
{
operationId = operation.Id,
status = operation.Status,
statusUrl
});
});This illustrates the core HTTP pattern: validate, create an operation, enqueue work, and return 202.
However, it is deliberately incomplete. The queue stores only the operation ID, while a real worker also needs access to the original request parameters. The example also requires registration of the store and queue, and it does not atomically coordinate storing the operation with enqueueing it.
For production, both the operation data and its scheduling state must be durable and recoverable.
Why the Background Worker Matters
The API endpoint should not perform the expensive report generation itself.
Instead, a background worker consumes accepted operations.
In our earlier guide on Background Services in ASP.NET Core, we explored how hosted services execute work independently of incoming HTTP requests. The same foundation allows long-running API operations to continue processing after the server returns HTTP 202 Accepted.
ASP.NET Core applications can host background services using BackgroundService.
A simplified worker structure looks like this:
public sealed class ReportWorker : BackgroundService
{
private readonly Channel<Guid> _queue;
private readonly ILogger<ReportWorker> _logger;
public ReportWorker(
Channel<Guid> queue,
ILogger<ReportWorker> logger)
{
_queue = queue;
_logger = logger;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
await foreach (
var operationId in _queue.Reader.ReadAllAsync(
stoppingToken))
{
_logger.LogInformation(
"Processing operation {OperationId}",
operationId);
// Load the request and operation record.
// Claim the operation.
// Execute the report job.
// Persist completion or failure.
}
}
}This is a structural example rather than a complete report processor.
The important idea is that the worker has its own execution lifecycle.
It can continue processing after the original HTTP request has ended.
For additional details, Microsoft’s ASP.NET Core hosted services documentation explains how hosted services and background workers integrate with the application’s host.
Avoid Fire-and-Forget Tasks Inside Endpoints
A tempting shortcut is:
app.MapPost("/api/reports", () =>
{
_ = Task.Run(async () =>
{
await GenerateReportAsync();
});
return Results.Accepted();
});This looks simple.
It is also unreliable for important work.
The task is not durably recorded.
The application may shut down while it is running.
Exceptions can be missed or poorly observed.
Request-scoped services may already be disposed when the task tries to use them.
There is no reliable retry or recovery mechanism.
A background task launched this way is not automatically a durable job.
For noncritical, disposable work, simple background execution can sometimes be acceptable.
For reports, imports, payments, exports, or business operations that must complete reliably, use managed background processing with durable state.
Why an In-Memory Queue Is Not Enough
A bounded Channel<T> is useful for coordinating work inside a running process.
It supports asynchronous producers and consumers and can apply backpressure when the queue reaches capacity.
For example:
builder.Services.AddSingleton(
Channel.CreateBounded<Guid>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false
}));This configuration allows up to 100 queued identifiers in the channel.
But the channel lives in application memory.
If the process crashes, its contents disappear.
That is a serious problem if the API has already returned 202 Accepted.
The client believes the operation was accepted, but the system may have lost the only reference to the work.
For durable processing, we need a persistent source of truth.
Persist the Operation Before Acknowledging It
A production-ready asynchronous API should record accepted work durably before returning HTTP 202.
The operation record might contain:
OperationId
OperationType
RequestPayload
Status
CreatedAt
StartedAt
CompletedAt
AttemptCount
NextAttemptAt
ResultLocation
ErrorDetailsFor example:
CREATE TABLE ApiOperations
(
Id UNIQUEIDENTIFIER PRIMARY KEY,
OperationType NVARCHAR(100) NOT NULL,
RequestPayload NVARCHAR(MAX) NOT NULL,
Status NVARCHAR(30) NOT NULL,
AttemptCount INT NOT NULL DEFAULT 0,
CreatedAt DATETIME2 NOT NULL,
StartedAt DATETIME2 NULL,
CompletedAt DATETIME2 NULL,
ResultLocation NVARCHAR(1000) NULL,
ErrorDetails NVARCHAR(MAX) NULL
);This example uses SQL Server syntax. The same architecture can be implemented with PostgreSQL or another transactional database.
The essential requirement is durability.
Once the API confirms acceptance, the system must have enough persisted information to resume or recover the operation.
The Database-Backed Job Pattern
A simple and reliable architecture uses the operation table itself as the source of pending work.
The API transaction inserts an operation record with status queued.
The worker periodically finds eligible queued records.
It claims an operation and marks it running.
After processing, it records completed or failed.
The workflow becomes:
POST /api/reports
↓
Validate request
↓
Insert operation into database
↓
Commit transaction
↓
Return 202 Accepted
↓
Worker claims queued operation
↓
Process report
↓
Persist result and final statusThis approach avoids a dangerous gap between saving the operation and publishing a separate queue message.
For small and medium workloads, a database-backed job table may be sufficient.
For larger systems, a message broker can distribute work across many workers.
Using a Message Broker
Suppose our platform processes thousands of reports every hour.
We might introduce a broker such as Azure Service Bus or RabbitMQ.
The architecture becomes:
ASP.NET Core API
↓
Durable operation record
↓
Message broker
↓
Worker service
↓
Result storage
↓
Operation status updateHowever, there is a subtle reliability problem.
What happens if the database transaction succeeds but publishing the message fails?
The operation exists, but no worker receives the notification.
What happens if the message is published but the database transaction fails?
A worker may receive a message referencing an operation that does not exist.
This is the classic dual-write problem.
The Transactional Outbox Pattern
The transactional outbox pattern solves this coordination problem when the database supports transactions covering both records.
Instead of saving an operation and immediately publishing a message in two independent steps, the application writes the operation and an outbox event in the same database transaction.
Database transaction
├── Insert operation
└── Insert outbox message
↓
Commit
↓
Outbox publisher
↓
Message broker
↓
WorkerIf the transaction fails, neither record is committed.
If it succeeds, both records exist.
A separate publisher reliably delivers pending outbox messages.
The publisher must tolerate retries and possible duplicate delivery.
This does not make the entire distributed workflow exactly-once. It makes the handoff recoverable and supports at-least-once delivery when implemented correctly.
That distinction becomes important when workers perform operations with external side effects.
Designing the Status Endpoint
Once an operation has been accepted, the client needs a way to check progress.
A status endpoint might look like this:
GET /api/operations/8f42a1While queued:
{
"operationId": "8f42a1",
"status": "queued",
"resultUrl": null,
"error": null
}While running:
{
"operationId": "8f42a1",
"status": "running",
"resultUrl": null,
"error": null
}After completion:
{
"operationId": "8f42a1",
"status": "completed",
"resultUrl": "/api/reports/8102/download",
"error": null
}The operation resource represents the state of the workflow.
It does not need to return the entire generated report.
For large files, returning a download URL is usually more efficient.
Implementing the Status Endpoint
With our earlier in-memory store, a simplified status endpoint looks like this:
app.MapGet("/api/operations/{id:guid}", (
Guid id,
InMemoryOperationStore store) =>
{
var operation = store.Get(id);
if (operation is null)
{
return Results.NotFound();
}
return Results.Ok(
new OperationStatusResponse(
operation.Id,
operation.Status,
operation.ResultUrl,
operation.Error));
});For a real application, replace the in-memory store with a database-backed repository.
The endpoint should also verify that the caller is authorized to view the operation.
Knowing an operation ID must not automatically grant access to another customer’s report or processing status.
Should Completed Operations Return 200 or 303?
There is more than one valid way to design completion behavior.
One option is to return HTTP 200 with a completed status document:
HTTP/1.1 200 OK{
"status": "completed",
"resultUrl": "/api/reports/8102/download"
}Another option is to return HTTP 303 See Other and redirect the client to the completed resource.
HTTP/1.1 303 See Other
Location: /api/reports/8102/downloadBoth can be useful.
Returning a status document keeps the workflow explicit and makes it easy for clients to inspect completion metadata.
Returning 303 can be convenient when the completed operation produces a resource that the client should retrieve directly.
Choose one behavior deliberately and document it in OpenAPI.
Do not surprise clients by changing completion semantics between endpoints.
Polling Without Overloading the API
The simplest way to monitor an operation is polling.
The client submits the request, receives an operation URL, and checks that URL periodically.
For example:
async function waitForOperation(statusUrl) {
while (true) {
const response = await fetch(statusUrl);
if (!response.ok) {
throw new Error("Unable to retrieve operation status");
}
const operation = await response.json();
if (operation.status === "completed") {
return operation.resultUrl;
}
if (operation.status === "failed") {
throw new Error(
operation.error ?? "Operation failed");
}
if (operation.status === "cancelled") {
throw new Error("Operation was cancelled");
}
await new Promise(resolve =>
setTimeout(resolve, 5000));
}
}This is a basic example. A production client should also support cancellation, a maximum waiting period, transient network failures, and server-provided polling guidance.
Polling is straightforward, but it creates traffic.
Imagine 100,000 active operations, with every client polling once per second.
That produces 100,000 status requests per second.
The status endpoint could become a bottleneck even if the actual background workers are healthy.
Use Retry-After and Adaptive Polling
A server can guide clients with:
Retry-After: 5This suggests waiting five seconds before another status check.
For longer operations, the server may recommend a longer interval.
Clients can also use exponential backoff with a maximum delay.
For example:
First poll: 2 seconds
Second poll: 4 seconds
Third poll: 8 seconds
Fourth poll: 15 seconds
Later polls: 15 secondsA small amount of random jitter can prevent many clients from polling simultaneously.
The goal is to balance timely updates with server efficiency.
A client should also stop polling after the operation reaches a terminal state.
Polling Is Not the Only Option
Some applications need immediate progress updates.
A user uploading a large video may want to see encoding progress in real time.
An administrator running a bulk migration may need continuous feedback.
Possible approaches include polling, Server-Sent Events, SignalR, and webhooks.
Polling is often the easiest default because it works across browsers, mobile apps, and external integrations.
SignalR can push progress updates to connected applications.
Server-Sent Events can stream one-way status updates over HTTP.
Webhooks can notify external systems when processing finishes.
These mechanisms can improve the user experience, but they should not become the only source of operation state.
If a client disconnects or misses a notification, it should still be able to retrieve the authoritative status.
Progress Percentages Can Be Misleading
Many applications display:
Processing: 65%But what does 65 percent actually mean?
If the job processes 10,000 records and has completed 6,500, the percentage may be meaningful.
However, some workflows contain stages with unpredictable durations.
For example:
Retrieve data
Validate data
Generate report
Upload file
Finalize metadataThe first stage might take five seconds.
The third might take two minutes.
Treating each stage as an equal percentage produces misleading progress.
For unpredictable workflows, consider reporting stages instead:
{
"status": "running",
"stage": "generating_document",
"message": "Generating report document"
}If you expose percentages, define what they measure and avoid implying precision that the system cannot provide.
Cancellation Requires Its Own Workflow
Long-running operations often need cancellation.
Suppose a customer starts a large export and realizes they selected the wrong date range.
The API might expose:
POST /api/operations/8f42a1/cancelThe server records a cancellation request.
A worker periodically checks whether cancellation has been requested and stops at a safe point.
This is usually better than assuming the HTTP request that submits cancellation can immediately terminate the operation.
Cancellation is often cooperative.
A worker may be executing a database query, uploading a file, or interacting with an external service.
Some actions cannot be interrupted safely.
A useful lifecycle may therefore include:
Running
↓
CancellationRequested
↓
CancelledThe client should understand that requesting cancellation does not guarantee immediate termination.
Cancellation Tokens Are Not Enough
In ASP.NET Core, HttpContext.RequestAborted represents cancellation associated with the HTTP request.
It is useful for ordinary request-scoped work.
But it should not control the lifetime of an accepted background operation.
After the API returns HTTP 202, the original request is finished.
The background job must have its own cancellation mechanism.
That may involve a cancellation flag stored in the database, a worker-specific cancellation token, or a cancellation command delivered through the job infrastructure.
The cancellation state must survive restarts if the operation itself is durable.
Retries Are Essential for Reliable Processing
Long-running operations often depend on systems that can fail temporarily.
A report worker may encounter a database timeout.
A cloud storage upload may fail.
A downstream API may return HTTP 503.
A message broker connection may be interrupted.
Some failures should be retried.
Others should not.
For example, a temporary network error may be retriable.
An invalid report request may be a permanent failure.
A robust worker distinguishes between transient and permanent errors.
A typical retry policy might use exponential backoff:
Attempt 1: immediately
Attempt 2: after 5 seconds
Attempt 3: after 15 seconds
Attempt 4: after 45 secondsThe retry policy should have limits.
Without them, one permanently failing operation can consume resources indefinitely.
Prevent Retry Storms
Retries can make an overloaded system worse.
Imagine a downstream reporting service becomes slow.
Hundreds of jobs time out.
Every worker retries immediately.
The service now receives more traffic than before.
More jobs fail.
More retries begin.
This creates a retry storm.
The backpressure principles from our earlier ASP Today article, Designing Backpressure in ASP.NET Core, apply directly here.
Use bounded concurrency, retry delays, jitter, queue limits, and load shedding where appropriate.
Retries should help the system recover, not amplify an outage.
Idempotency Prevents Duplicate Work
Consider a mobile application submitting:
POST /api/reportsThe server accepts the request and stores the operation.
But the network connection fails before the client receives the HTTP 202 response.
The client does not know whether the request succeeded.
It retries.
Without protection, the API may create two identical report jobs.
For expensive operations, this wastes resources.
For financial or externally visible operations, duplicates can cause serious problems.
An idempotency key helps identify repeated submissions of the same logical request.
For example:
POST /api/reports
Idempotency-Key: report-2026-10-customer-125The server stores the key together with the accepted operation.
If the same authorized client submits the same logical request again using that key, the server returns the existing operation rather than creating a duplicate.
Implementing Idempotency Correctly
Idempotency requires more than checking whether a key exists.
The server should associate the key with the caller or tenant, record a fingerprint of the request, and enforce uniqueness transactionally.
Otherwise, two concurrent requests may both pass a preliminary existence check and create duplicate jobs.
The API should also reject attempts to reuse the same key for a materially different request.
For example:
Same key + same request
↓
Return existing operation
Same key + different request
↓
Return conflictA database unique constraint is often part of the implementation.
The retention period for idempotency keys must also be documented.
Idempotency at submission time prevents duplicate accepted operations.
Worker-level idempotency is still necessary because message delivery and job execution may occur more than once.
Exactly-Once Execution Is Harder Than It Sounds
Suppose a worker successfully uploads a report.
Immediately afterward, the process crashes before marking the operation completed.
When the worker restarts, the operation still appears unfinished.
It may retry the upload.
Now the report exists twice.
This is why durable systems should assume that operations can be retried after partial completion.
A useful design makes side effects idempotent.
For example, store a report under a deterministic object key derived from the operation ID.
If the worker retries, it can detect that the expected artifact already exists and verify whether it is complete.
The same principle applies to database updates, external API calls, and event publishing.
The goal is not to assume execution happens exactly once.
It is to make repeated execution safe.
Worker Ownership and Leases
When several workers process jobs concurrently, they must avoid claiming the same operation simultaneously.
A common solution uses a lease.
A worker claims an eligible job for a limited period.
The operation record includes information such as:
LeaseOwner
LeaseExpiresAt
AttemptCountThe worker periodically renews the lease while processing.
If the worker crashes, the lease eventually expires.
Another worker can reclaim the operation.
This supports recovery without permanently locking work to a failed process.
However, lease expiration introduces another risk.
A slow worker may continue running after its lease expires, while another worker begins processing the same operation.
Production designs should use conditional state updates, ownership tokens or fencing mechanisms, and idempotent side effects to protect against stale workers.
Handling Worker Crashes
Imagine a report worker crashes halfway through generating a file.
A reliable system needs to answer several questions.
Was the operation durably recorded?
Can another worker identify it as unfinished?
Is the partial output safe to discard or resume?
Will retrying cause duplicate side effects?
How many attempts have already occurred?
Should the operation eventually move to a permanent failed state?
These are not edge cases.
They are normal concerns in any system where work continues independently of the HTTP request.
A background-processing architecture is production-ready only when it can recover from interruption.
Graceful Shutdown Protects In-Flight Work
ASP.NET Core applications may restart during deployments, scaling events, or infrastructure maintenance.
A background worker should respond to shutdown signals and stop claiming new jobs.
Where practical, it should finish its current work within the allowed shutdown window.
If it cannot finish, the job must remain recoverable.
This connects to our earlier article on Graceful Shutdown and Application Lifecycle in ASP.NET Core.
Graceful shutdown reduces avoidable interruptions, but it is not a durability guarantee.
A server can crash without receiving a shutdown signal.
Durable job state, leases, and idempotency remain necessary.
Separating API Servers From Workers
A small application can host both HTTP endpoints and background workers in the same ASP.NET Core process.
That is a reasonable starting point.
As workloads grow, however, long-running jobs may compete with HTTP requests for CPU, memory, database connections, and other resources.
For example, generating large PDFs may consume significant CPU and memory.
If the same process also serves customer-facing requests, report generation can increase API latency.
A common scaling strategy separates the API and worker processes.
Clients
↓
ASP.NET Core API instances
↓
Durable job store / broker
↓
Background worker instances
↓
Database / object storageThis allows independent scaling.
The API tier can scale based on request traffic.
The worker tier can scale based on queue depth, processing latency, or resource consumption.
Backpressure Still Applies at the API Boundary
An asynchronous API can return HTTP 202 quickly, but that does not mean it has unlimited capacity.
Imagine the system accepts 100,000 report requests per minute.
Workers can complete only 10,000 per minute.
The backlog grows continuously.
Returning 202 for every request merely moves the overload from HTTP connections into the job queue.
A reliable API needs admission control.
It may limit the number of outstanding operations per customer, enforce queue-capacity thresholds, or reject new work temporarily.
For example:
HTTP/1.1 503 Service Unavailable
Retry-After: 60Or, for a documented per-client quota:
HTTP/1.1 429 Too Many Requests
Retry-After: 60The API should return 202 only after the work has been durably accepted.
If the system cannot accept responsibility for the operation, it should not pretend otherwise.
Securing Long-Running Operations
Operation identifiers should not act as authorization tokens.
A user who knows another customer’s operation ID must not be able to retrieve its status, result, or error details.
Every status, cancellation, and download endpoint should enforce appropriate authorization.
For a multi-tenant application, operation records should include tenant ownership.
A status query might conceptually require:
OperationId = requested ID
AND
TenantId = authenticated tenantResult URLs also require care.
A generated report may contain sensitive financial information.
Use authenticated download endpoints or appropriately scoped, short-lived signed URLs.
Avoid exposing internal exception details through public operation-status responses.
The public API can return a safe error code and message while the server retains detailed diagnostics.
Operation Expiration and Data Retention
Completed operations should not remain in the database forever.
Imagine processing millions of jobs each month.
Even small status records eventually become a significant storage and indexing concern.
Define retention policies for completed, failed, and cancelled operations.
For example, an application may retain operation metadata for a specified period and store generated reports under a separate lifecycle policy.
The exact duration depends on business, compliance, and recovery requirements.
Clients must know what happens after an operation expires.
A status endpoint may return HTTP 404 if the resource is no longer available, or HTTP 410 Gone when the API deliberately communicates that the resource previously existed but has been removed.
Whichever policy you choose, document it.
Observability for Asynchronous Workflows
Traditional request monitoring is not enough for long-running APIs.
The initial HTTP request may finish in 30 milliseconds.
The background job may take 15 minutes.
If monitoring tracks only HTTP response time, the API appears extremely fast even when users wait a long time for results.
Measure the entire workflow.
Important metrics include:
Time from request acceptance to worker start
Time spent processing
Total time from acceptance to completion
Queue depth and oldest queued job age
Completion, failure, cancellation, and retry rates
Lease expiration and recovery counts
The distinction between queue time and processing time is especially useful.
A job may require only 20 seconds of actual work but wait 10 minutes before a worker becomes available.
Optimizing the job’s code will not solve that queueing problem.
Correlating HTTP Requests With Background Jobs
Distributed tracing becomes more challenging when work crosses an asynchronous boundary.
The initial request creates an operation.
A worker may process it several minutes later, possibly on another server.
The system should preserve correlation information such as the operation ID, trace context, tenant identifier, and job type.
For example:
HTTP request
↓
OperationId: 8f42a1
↓
Database record
↓
Broker message
↓
Worker execution
↓
Report upload
↓
Completion recordLogs, metrics, and traces should make it possible to follow this sequence.
In OpenTelemetry, asynchronous producer and consumer spans can be connected through propagated trace context or span links, depending on the processing model.
This gives operators a way to investigate why an accepted operation is still unfinished.
Testing the 202 Accepted Workflow
A long-running API requires more than endpoint unit tests.
Start with the HTTP contract.
Submit a valid request.
Verify that the API returns 202.
Verify that the response includes a usable operation-status URL.
Verify that the operation exists in durable storage.
Next, test status transitions.
A newly accepted operation should appear as queued.
A claimed operation should appear as running.
A successfully completed operation should expose its result.
A failed operation should expose a safe, documented failure response.
Then test recovery.
Restart the worker during processing.
Verify that the job becomes eligible for recovery.
Submit the same request with the same idempotency key.
Verify that only one logical operation is created.
Attempt to retrieve another tenant’s operation.
Verify that authorization prevents access.
These tests are essential because the most serious failures occur between stages, not necessarily inside the endpoint itself.
Testing Failure Scenarios
Happy-path testing is not enough.
A useful test suite deliberately introduces failures.
For example, make the database temporarily unavailable.
Disconnect the message broker.
Terminate a worker while it holds a lease.
Make an object-storage upload fail after partial progress.
Force a retry after a successful external side effect but before the operation is marked completed.
Simulate a client disconnecting immediately after submitting the request.
Each scenario tests a different reliability guarantee.
The key question is whether the system can recover without losing accepted work or creating unsafe duplicate effects.
Performance Testing Asynchronous APIs
In our previous guide on Benchmarking ASP.NET Core Applications, we explored why performance must be measured across the complete system.
Asynchronous APIs introduce additional measurements.
The initial HTTP request may be fast because it only validates and stores work.
But overall user experience depends on the time until the operation completes.
Consider:
HTTP acceptance latency: 40 ms
Queue wait time: 90 sec
Processing time: 20 sec
Total completion time: 110 secImproving acceptance latency from 40 milliseconds to 20 milliseconds will barely change the user’s experience.
Reducing queue wait time from 90 seconds to 10 seconds will make a dramatic difference.
For long-running workflows, measure end-to-end completion latency, not only HTTP request latency.
When Should You Use the 202 Accepted Pattern?
The 202 Accepted pattern is especially appropriate when an operation takes longer than a typical interactive request, requires durable processing, can continue independently of the client, or produces a result that can be retrieved later.
Examples include financial report generation, large data imports, bulk email preparation, media transcoding, document conversion, complex analytics, and background reconciliation.
It is less useful for operations that complete quickly and predictably.
A simple database lookup should not become a queued job merely because asynchronous processing sounds modern.
Every background workflow introduces additional infrastructure, state management, monitoring, and failure handling.
Use the pattern when those costs solve a real problem.
A Production-Ready Architecture
Let’s bring the pieces together.
A mature long-running API can use the following architecture:
A durable, recoverable asynchronous processing workflow. The broker is optional when workers claim jobs directly from the database.
The API accepts responsibility for a job only after it has been durably recorded.
The broker distributes work when needed.
Workers process jobs independently.
Operation state is persisted.
Clients can check progress without maintaining an open connection.
Retries and leases support recovery.
Idempotency protects against duplicate execution.
Authorization protects operation details and results.
Observability makes the workflow understandable in production.
This architecture is more complex than a synchronous endpoint, but that complexity serves a clear purpose: making long-running work reliable.
Common Mistakes to Avoid
The most common mistake is assuming that using async and await automatically creates an asynchronous API workflow. It does not. The HTTP request still waits unless the API explicitly separates acceptance from completion.
Another mistake is returning HTTP 202 before the job has been durably stored. If the process crashes, the operation may disappear.
Some developers launch untracked Task.Run work directly from endpoints. This makes failure handling, shutdown, and recovery difficult.
Others use in-memory queues for business-critical operations without considering restarts.
Polling too frequently can overwhelm the status endpoint.
Missing idempotency allows duplicate submissions.
Missing worker ownership can lead to concurrent processing of the same job.
Unbounded retries can amplify outages.
Finally, some systems monitor only HTTP acceptance latency and ignore how long users actually wait for completed results.
A reliable asynchronous API must address the entire operation lifecycle.
Coming Next
In our next article, we’ll explore Server-Sent Events in ASP.NET Core: Building Lightweight Real-Time Data Streams.
The 202 Accepted pattern gives clients a reliable way to submit long-running work and check its progress.
But polling is not always the most efficient way to deliver updates.
Server-Sent Events provide a lightweight mechanism for streaming one-way updates from an ASP.NET Core server to connected clients.
We’ll explore event streams, reconnection, event identifiers, cancellation, connection management, and the situations where SSE is a better choice than polling or WebSockets.
Final Thoughts
Long-running operations expose an important limitation of ordinary request-response APIs.
Not every useful operation can finish while a client waits.
Trying to force every task into a single HTTP request creates unnecessary timeouts, fragile connections, and difficult recovery scenarios.
The HTTP 202 Accepted pattern offers a cleaner alternative.
The API accepts the request, records the work, and returns an operation identifier.
Background workers perform the processing.
Clients retrieve status and results independently.
Durable storage protects accepted work.
Retries, leases, and idempotency make failures recoverable.
Backpressure prevents unlimited queue growth.
Observability connects the initial request with the eventual outcome.
The result is an API that can handle complex work without requiring clients to remain connected throughout the process.
A reliable asynchronous API does not promise that work is already complete. It promises that accepted work can be tracked, processed, and recovered responsibly.
That is what turns HTTP 202 Accepted from a simple status code into a production-ready application pattern.
Subscribe Now
Enjoying the series? Subscribe to ASP Today for practical ASP.NET Core tutorials, production architecture deep dives, and advanced API engineering strategies. Join our Substack Chat to discuss long-running operations, HTTP 202 Accepted, background workers, durable queues, idempotency, and building reliable asynchronous APIs for modern .NET applications.


