How long-running operations get modeled in a REST-compliant way without making the client wait forever
Video transcoding, a large data export, or complex report generation rarely fits within the response time of a single synchronous HTTP request. The long-running operations pattern solves this by immediately confirming the operation with HTTP 202 Accepted, while the actual work runs asynchronously in the background and the client polls progress through a separate status endpoint.
Table of Contents
- 1. Why synchronous requests fail for long operations
- 2. HTTP 202 Accepted as an immediate confirmation
- 3. The status endpoint for polling
- 4. Sensible polling intervals instead of aggressive continuous polling
- 5. Webhooks as an alternative to pure polling
- 6. Error handling for failed long-running operations
- 7. How long job status information should be retained
- 8. Making running operations cancellable
- 9. Long-running operations at a glance
- 10. Summary
- 11. FAQ
1. Why synchronous requests fail for long operations
HTTP requests have hard time limits in practice, set by load balancers, reverse proxies, browsers, and the PHP-FPM process itself, typically between 30 and 120 seconds. An operation that needs several minutes or even hours (a large data export, batch image processing, a complex report calculation over millions of records) reliably exceeds these limits, no matter how well-optimized the server code is.
A naive attempt to simply raise the timeout only shifts the problem instead of solving it, and makes the application vulnerable to hung worker processes that stay blocked for the entire duration of the operation. The long-running operations pattern solves the problem structurally by decoupling HTTP response time from actual processing time.
2. HTTP 202 Accepted as an immediate confirmation
HTTP 202 Accepted signals that a request has been accepted for processing, but the processing itself is not yet complete, unlike HTTP 200 OK or HTTP 201 Created, which signal an already completed operation. The response to a long-running operation trigger should therefore reply with 202 immediately, typically within milliseconds, combined with a job ID and a Location header pointing to the associated status endpoint.
This status code is part of the HTTP standard itself and is correctly interpreted by REST clients and frameworks as "accepted, but not finished," unlike a misused 200 with a self-invented status field in the body that many generic HTTP tools won't automatically recognize.
<?php
declare(strict_types=1);
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Messenger\MessageBusInterface;
final class ReportGenerationController
{
public function __construct(private readonly MessageBusInterface $bus) {}
public function trigger(array $reportParams): JsonResponse
{
$jobId = bin2hex(random_bytes(16));
$this->bus->dispatch(new GenerateReportMessage($jobId, $reportParams));
return new JsonResponse(
['jobId' => $jobId, 'status' => 'pending'],
202,
['Location' => "/api/reports/jobs/{$jobId}"]
);
}
}
3. The status endpoint for polling
The status endpoint referenced through the Location header should at minimum return the current status (pending, processing, completed, failed), ideally supplemented with a numeric progress value and an estimated remaining duration, where this can be sensibly calculated. As long as the job is still running, the endpoint responds with HTTP 200 and the current status, not 202, because the status query itself completes immediately even if the referenced job hasn't finished yet.
Once the job is complete, the status endpoint should either include the finished result directly (for smaller results) or provide a link to the result (for larger results like generated files), together with a clear completed status, so the client unambiguously knows further polling is no longer needed.
4. Sensible polling intervals instead of aggressive continuous polling
A client polling the status endpoint every 100 milliseconds creates unnecessary load, while polling too infrequently (say, every 30 seconds) unnecessarily degrades perceived responsiveness for short jobs. A proven pattern is exponentially increasing polling, starting at short intervals (about 1 second) and ramping up to a maximum interval (about 10 seconds), to detect short jobs quickly without creating unnecessary load for long jobs.
The status endpoint can additionally support this process by explicitly telling the client through a Retry-After header how long to wait before the next query, instead of leaving the client alone with fixed, hardcoded client-side logic. This header is part of the HTTP standard and is automatically respected by many HTTP client libraries.
5. Webhooks as an alternative to pure polling
For clients that can operate their own publicly reachable URL, a webhook callback is often more efficient than polling: instead of repeatedly asking, the client registers a callback URL in the original request, which the server calls with the result once the job completes. This approach drastically reduces unnecessary traffic, especially for jobs with highly variable, hard-to-predict runtimes.
Webhooks bring their own complexity, such as retry logic for failed delivery, signature verification against forged callbacks, and the requirement that the client be publicly reachable, which is why many APIs offer both mechanisms in parallel: polling as a simple default option, webhooks as a more efficient option for clients that can justify the extra effort.
6. Error handling for failed long-running operations
If a job fails during asynchronous processing, the status endpoint should return an explicit failed status with a structured error description, instead of leaving the job indefinitely in pending status, which would force the client into endless, pointless polling. A job-level timeout that automatically marks a job as failed after a maximum runtime additionally prevents hung background processes from going unnoticed.
For jobs that were partially successful (say, a data export that stops at 80 percent), the status endpoint should explicitly communicate this as its own state, together with information about which part already completed, instead of blanket-marking the entire job as failed.
7. How long job status information should be retained
Job status information should not be kept indefinitely, but automatically cleaned up after a reasonable period (about 7 to 30 days, depending on the use case), so the storage requirements of the status store (often Redis or a dedicated database table) don't grow uncontrollably. Fetching an expired job status should respond with HTTP 404 Not Found, not a misleading pending status.
This retention period should be clearly communicated in the API documentation, so clients know how long they can rely on a job result being available before they need to persist it themselves.
8. Making running operations cancellable
An often overlooked but practically important aspect is the ability to explicitly cancel a running job, for example when a user wants to stop an accidentally started, resource-intensive export instead of letting it run to completion and discarding the result afterward. A DELETE on the job status endpoint is a natural, REST-compliant way to request cancellation.
It's important that the cancellation request itself is handled asynchronously: the server confirms the cancellation request immediately, but the actual background process needs to periodically check itself whether a cancellation was requested (for example via a flag in the job status store), since an immediate, hard interruption of a running process can leave inconsistent intermediate states.
9. Long-running operations at a glance
The table below compares polling and webhooks as communication mechanisms.
| Mechanism | Advantage | Disadvantage |
|---|---|---|
| Polling | Simple to implement, no public client URL needed | Unnecessary traffic if interval is chosen poorly |
| Webhooks | Efficient, immediate notification on completion | Client needs a publicly reachable URL |
| 202 Accepted | Immediate confirmation, clear HTTP semantics | Requires an additional status endpoint |
| Retry-After header | Controls polling interval server-side | Not automatically respected by all clients |
Mironsoft
OpenAPI design, Symfony APIs, and API security
APIs that external teams can integrate without back-and-forth questions?
We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.
API Review
Checking the OpenAPI spec, error formats, and status codes for consistency.
Symfony Implementation
Using DTOs, Serializer, and Validator for clean, type-safe request/response models.
Security Audit
Hardening rate limiting, auth schemes, and input validation against real attack surfaces.
10. Summary
Long-Running Operations: The Essentials at a Glance
202 Accepted
Signals immediate acceptance of an operation that isn't yet complete, with a job ID and Location header.
Status endpoint
Returns current status, progress, and on completion the result or a link to it.
Polling intervals
Exponentially increasing instead of fixed, ideally controlled via the Retry-After header.
Webhooks as alternative
More efficient than polling for clients with a publicly reachable callback URL.