Modeling Long-Running Operations with 202 Accepted and Polling
AI generated
{ }
GET
Async Pattern · HTTP 202
Long-Running Operations with 202 Accepted
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.

15 min read 202 Accepted · Async Pattern Symfony Messenger

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.

11. FAQ: Long-Running Operations: The Essentials at a Glance

1From what expected runtime does this pattern pay off?
It becomes relevant from around 5 to 10 seconds, well before the typical 30 to 120 second timeout limits of load balancers.
2Do I have to use Symfony Messenger for asynchronous processing?
No, any message queue solution works. Symfony Messenger is the natural, well-integrated choice within the Symfony ecosystem.
3How do I calculate a sensible remaining-time estimate?
Based on historical averages of similar, already completed jobs, not on a static estimate that ignores the actual data volume.
4Should the job ID be predictable or random?
Random and sufficiently long, to prevent other users from guessing or enumerating foreign job IDs.
5Can I combine long-running operations with bulk endpoints?
Yes, very large bulk batches are a common, practical use case for exactly this pattern.
6How do I test long-running operations automatically?
With integration tests that use the synchronous message bus transport for tests, to simulate asynchronous processing deterministically without real wait time.
7What happens if the client never polls the status?
The job keeps running in the background regardless, the result stays stored until the retention period expires, even if it's never queried.
8Is WebSocket a better alternative to polling?
For real-time progress display, yes, see server-sent events vs. WebSockets for API real-time updates. For simple completion notification, webhooks are often sufficient.
9How do I secure the status endpoint against unauthorized access?
Through the same authentication as the original trigger endpoint, combined with a check that the querying user is also the creator of the job.
10Should 202 Accepted also be used for fast operations?
No, for operations that typically complete in under a second, a direct synchronous response with 200 or 201 is simpler and more intuitive for clients.