Long-Running Operations: 202 and the Job Resource
A request that takes 15 minutes cannot pretend to be request/response — some timeout between the client and your handler will fire first, and a retry starts the 15 minutes again. Return 202 with a job resource instead, and the operation becomes observable, retry-safe and cancellable.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Why the synchronous shape breaks — count the timeouts
Hold POST /generate-report open for 15 minutes and you're betting against every timeout in the chain, and the chain always wins. The client SDK defaults to 30–60s. The load balancer idles out at 60s. The gateway kills upstreams at 120s (HTTP Debugging: 502, 503 and 504 Are Different Failures is this exact failure). Some corporate proxy on the client's side has opinions too. The first of these to fire returns an error for work that is *still running* — and now the client knows nothing: not whether the report is being built, not how to find it, not whether trying again is safe.
The retry makes it worse in a specific, compounding way: the client re-POSTs, starting a second 15-minute report while the first still runs. Under any load, heavy jobs pile up behind their own retries — the Retries and Timeouts as Contract Guidance storm with 15-minute payloads. And the failure is environmental, so it passes tests: staging's small data finishes in 20 seconds; the customer with 2M rows meets the 60-second gateway first (Unbounded Collections: The Anti-Pattern With a Fuse is the same discover-at-scale shape). Raising every timeout in the chain is the tempting non-fix — it needs coordination across infrastructure you don't own (the client's proxy, their SDK config), holds a connection's worth of resources hostage per in-flight job, and still loses to the user closing the laptop.
Client SDK timeout 30–60s (their default, not yours) Corporate proxy 60–300s (invisible to both of you) Load balancer idle 60s (yours, configurable-ish) API gateway upstream 120s (yours) Report generation ~900s ← loses to ALL of the above First timeout fires → client sees 504 → work continues unseen → client retries → second job starts → queue doubles → …the biggest customers, with the slowest reports, retry most.
The async shape: the work becomes a resource
The fix is a contract change, not a tuning exercise: split *triggering* the work from *obtaining* the result. POST /reports validates the request, enqueues the work, and returns 202 Accepted in milliseconds — with a body describing the job and a Location pointing at its status resource. The client polls GET /reports/{id} (or subscribes — How the Client Learns the Job Finished compares the channels), watching state move through the machine The Async Job Pattern defines, and follows result_url when the state says ready.
Every broken property flips. Timeouts become irrelevant — each individual request is fast. Retries become safe — the trigger takes an Idempotency Keys: The Mechanism key, so a re-POST returns the *same job* instead of starting a twin. Progress becomes possible — the status resource can carry progress, stage names, partial counts. Cancellation becomes a first-class operation on the job resource. Failure becomes articulate: the job ends in failed with a machine-readable error, instead of a connection reset that says nothing (The Error Model: Structure Over Apology). The cost is equally real: two-plus requests where there was one, client-side polling or subscription logic, and job state the server must persist and expire.
The 202 matters as a semantic, not a formality: it says *accepted, not done* — validation passed and the work is owned, but nothing about the outcome. Returning 200/201 from a trigger whose work hasn't happened teaches clients the work succeeded, and they build on that lie (Status Codes Clients Can Branch On).
POST /reports HTTP/1.1
Idempotency-Key: rpt_2024q3_acct9
Content-Type: application/json
{ "type": "quarterly", "account": "acct_9" }
# later:
GET /reports/rep_71 HTTP/1.1HTTP/1.1 202 Accepted
Location: /reports/rep_71
Retry-After: 5
{ "id": "rep_71", "state": "queued",
"created_at": "2026-08-25T10:00:00Z" }
# the GET, 11 minutes later:
HTTP/1.1 200 OK
{ "id": "rep_71", "state": "succeeded",
"progress": 100,
"result_url": "/reports/rep_71/download",
"result_expires_at": "2026-09-01T10:00:00Z" }Choosing the boundary — and hybrid honesty
Not everything deserves the machinery. The decision variable is *worst-case honest duration* against the shortest timeout you don't control — in practice a budget of a few seconds. Sub-second p99: synchronous, obviously. Seconds: synchronous with care (an explicit deadline, and a documented story for slow outliers). Beyond ~10 seconds worst-case, or unbounded-by-input-size (the export whose duration scales with the customer): async by contract, even though the *median* case finishes fast — because contracts are set by the worst case a client must survive, not the median that demos well.
Two hybrids cover the awkward middle. Wait-then-degrade: the trigger accepts Prefer: wait=5 — if the work finishes inside the window, respond 200 with the result; otherwise 202 with the job. Fast cases stay one-call simple, slow cases stay honest, and the client must handle both (which the docs must say loudly). Async-with-sync-façade: internally everything is a job; a convenience endpoint waits and unwraps for script consumers. Both keep one invariant: *the synchronous path is a bonus, the asynchronous path is the contract*. The reverse — sync as the contract, async bolted on when it breaks — is the migration you're trying to avoid, done under incident pressure.
1POST /generate-report (holds connection)2→ 200 + report body # staging: 20s. fine.3 4# production, big customer:5→ 504 Gateway Timeout @ 120s # work still running6# client retries → second job7# support asks ops to "raise the timeout"8# ops raises it to 300s → now meets the9# client SDK's 60s timeout instead10# → async retrofit, breaking, under pressure1POST /reports2Prefer: wait=53Idempotency-Key: rpt_q3_acct94 5→ fast case: 200 { state: "succeeded",6 result_url: … }7→ slow case: 202 Location: /reports/rep_718 { state: "queued" }9 10# client contract: handle BOTH answers.11# retry re-POST with same key → same job.12# GET /reports/rep_71 until terminal state.The bad shape's contract is a bet on every timeout in a chain nobody fully controls, and it loses precisely for the biggest customers. The good shape makes async the promise and sync an optimization — so scale changes latency, never the contract.
Key points
- A held connection races every timeout in the chain — SDK, proxy, LB, gateway — and the first to fire orphans work that is still running.
- Timeout errors on sync long operations trigger retries that duplicate the heaviest work you have, compounding load exactly when it hurts.
- The async shape splits trigger from result: 202 + Location + job resource; each request is fast, so timeouts stop mattering.
- Idempotency keys on the trigger make retries return the same job — without them, async inherits the duplicate-work bug with better manners.
- 202 means accepted-not-done; returning 200 from a trigger teaches clients a lie they will build on.
- Decide by worst-case duration against timeouts you don't control; hybrids (Prefer: wait) keep fast cases simple while the async path stays the contract.
The Async Job Pattern
Change the contract and observe which guarantee moves.
POST /generate-report … 30s … load balancer timeout → 504 Gateway Timeout Did the report generate? Unknown. Retry? It may run twice.
—
The 202 + job resource makes the long operation retryable (POST with an idempotency key), observable (states are contract), and cancellable — none of which the held-open connection could promise.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: ships
POST /generate-reportsynchronous; staging data finishes in 20 seconds and the demo is clean. - 2Big customer → API: their data takes 6 minutes; the gateway 504s at 120s while the worker grinds on.
- 3Customer's script → API: retries on 504 — three more 6-minute jobs enter the queue behind the first.
- 4Ops → gateway: raises the timeout to 600s; now the client SDK's 60s timeout fires instead, and connections pile up at the LB.
- 5Team → API v2: retrofits the job pattern as a breaking change, migrating integrations under incident pressure — the design that was one
202away on day one.
- The biggest customers hit it first and hardest: duration scales with their data, so your most valuable accounts get timeouts and duplicate work.
- Orphaned work burns compute for results nobody can retrieve — the client got an error and has no handle to the output.
- Retry-duplicated heavy jobs saturate workers and queues, delaying every other tenant's jobs — one customer's timeout loop becomes everyone's backlog.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Make any operation with unbounded or >10s worst-case duration async by contract: 202 + Location + job resource, with the state machine from [[async-job-pattern]].
- • Require an idempotency key on triggers so retries converge on the existing job instead of duplicating the work.
- • Return Retry-After polling hints and offer push channels for completion where polling costs matter ([[job-completion-notification]]).
- • For the middle ground, support Prefer: wait=N with both response shapes documented — and keep async as the contract, sync as the bonus.
- • Track job duration percentiles per operation and per tenant — the p99-by-tenant view is what predicts who meets a timeout next in any remaining sync paths.
- • Alert on trigger-retry patterns (same principal, same parameters, close together) on endpoints without idempotency keys: duplicate heavy work in progress.
- • Watch gateway/LB timeout counts against worker completion logs; 504s paired with later successful completions are orphaned-result events.
- • A sync endpoint can gain the async path compatibly: keep sync as default, add Prefer: wait / an async opt-in, migrate consumers with telemetry, then flip the default with notice ([[api-migration]]).
- • The job resource absorbs new needs additively — progress detail, stage names, partial results, cancellation — without touching the trigger contract.
- • Result retrieval can move (inline → result_url → presigned storage download) as sizes grow, as long as the job resource keeps pointing the way ([[file-upload-apis]] mirrors the pattern for uploads).
- • Client complexity is real and permanent: two response shapes or a polling loop, versus one await — script consumers feel it most.
- • The server takes on job state: persistence, expiry, status queries at poll frequency — a stateful subsystem where a stateless handler used to be.
- • End-to-end latency for fast cases worsens slightly (enqueue + poll interval) unless you add the wait-hybrid, which adds its own dual-shape complexity.