Real-Time202 acceptedasyncjob resourcepollingtimeoutslong-running

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.

▶ Run the labFollow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
This operation takes longer than any hop in the chain will keep a connection open — so what does the client get back now, and how does it reach the result later?
Consumers
Clients triggering heavy work: report and export generation, video transcoding, ML batch inference, bulk imports, infrastructure provisioning — often from UIs that must show progress and scripts that must not hold a connection for 20 minutes.
The promise
The trigger returns immediately with a resource that names the work; its state is observable at a stable URL; retrying the trigger doesn't duplicate the work; and the result, once ready, is retrievable for a stated period — with the whole flow honest about being asynchronous.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

The 15-minute POST vs the chain it must survive
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).

Trigger, observe, fetch — three cheap requests replace one impossible one
Request
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.1
Response
HTTP/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.

Synchronous contract, discovered-at-scale failure
1POST /generate-report (holds connection)
2200 + report body # staging: 20s. fine.
3
4# production, big customer:
5504 Gateway Timeout @ 120s # work still running
6# client retries → second job
7# support asks ops to "raise the timeout"
8# ops raises it to 300s → now meets the
9# client SDK's 60s timeout instead
10# → async retrofit, breaking, under pressure
Async contract, sync as a documented bonus
1POST /reports
2Prefer: wait=5
3Idempotency-Key: rpt_q3_acct9
4
5fast case: 200 { state: "succeeded",
6 result_url: … }
7slow case: 202 Location: /reports/rep_71
8 { 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.

The Async Job Pattern
The report takes minutes. Instead of holding the connection open, the operation becomes a resource with observable state.
Synchronous version
POST /generate-report
… 30s … load balancer timeout
→ 504 Gateway Timeout

Did the report generate? Unknown.
Retry? It may run twice.
Job version
queuedrunningsucceeded
Exchange log

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.

  1. 1
    Team → API: ships POST /generate-report synchronous; staging data finishes in 20 seconds and the demo is clean.
  2. 2
    Big customer → API: their data takes 6 minutes; the gateway 504s at 120s while the worker grinds on.
  3. 3
    Customer's script → API: retries on 504 — three more 6-minute jobs enter the queue behind the first.
  4. 4
    Ops → gateway: raises the timeout to 600s; now the client SDK's 60s timeout fires instead, and connections pile up at the LB.
  5. 5
    Team → API v2: retrofits the job pattern as a breaking change, migrating integrations under incident pressure — the design that was one 202 away on day one.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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).
What it costs
  • • 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.

Misconceptions

Claim
“We'll just raise the timeouts.”
Reality
You control at most half the chain — client SDKs, corporate proxies and browser limits are not yours to configure. And every raised timeout holds a connection's resources hostage longer per job, converting a latency problem into a capacity one. The chain always wins; change the contract instead.
Claim
“Async APIs are worse developer experience than a simple await.”
Reality
A single await that 504s at two minutes with work still running is the worst experience an API can offer — an error that is neither failure nor success. A job resource with progress, honest states and an SDK helper that polls internally routinely beats it; the façade can even *look* like one await.
Claim
“Our operation is fast, so this doesn't apply.”
Reality
Fast at today's median. The contract question is the worst case as data grows — an operation whose duration scales with customer size will cross the timeout boundary precisely for the customer least willing to tolerate it. Bound the worst case or make the contract async.

Apply it