Real-Timeasyncjobs202 Acceptedstate machinepollingcancellation

The Async Job Pattern

POST the operation, get 202 and a job resource, let a worker do the work, poll or be notified, fetch the result. The pattern is simple; the contract is not — queued/running/succeeded/failed/cancelled is a state machine with retention, cancellation, progress and idempotent creation that consumers build whole workflows on.

Follow the failure

Frame the contract

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

Design question
When the work outlives the request, what resource does the client hold, which states can it be in, and how does the client get the result, cancel, or retry safely?
Consumers
Clients kicking off reports, exports, imports, media processing, model runs and bulk operations; UIs that need to show progress and a cancel button; integrations that fire-and-forget thousands of jobs and reconcile later; and orchestrators (including agents) that chain jobs and need every state to be explicit.
The promise
A job is a first-class resource with an explicit, documented state machine, a stable id returned immediately, progress the client can read, a cancellation the client can request, a result with a documented retention period, and creation that is safe to retry.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

From a blocking call to a resource

Long-Running Operations: 202 and the Job Resource made the case that holding a connection open for 15 minutes bets against every timeout in the chain. The escape is to change what the request *means*: POST /reports no longer means "produce the report", it means "create a job that will produce the report". The server validates, persists a job row, enqueues it, and returns 202 Accepted with the job's id and a Location — in milliseconds, regardless of how long the work takes. Everything after that is a conversation about the job resource.

That shift moves the hard parts into the contract, where they belong. The job needs an id the client can store. It needs a status drawn from a documented set. It needs a place to put the result (inline for small results, a URL for large ones), a place to put the error when it fails, and an answer to "how long will this resource exist". Skip any of these and the client invents the answer: polls forever, assumes failed is final when it was a transient retry, or stores a result URL that 404s next week.

The worker side is architecture (Background Jobs and Workers, Message Queues — the queue, the retry policy, the visibility timeout); the API side is the contract that hides all of that behind a resource whose behavior is stable across implementations. A client should never learn which queue technology is behind /jobs/{id}.

Idempotency-Keypersistenqueuedequeuewrite result, update statusRetry-Afterread status/progressClientPOST /reports → 202GET /jobs/{id}Job resource (queued)QueueWorker (running)Result stored (succeeded)
UserLLMAgentToolDataDecisionHumanGuardrail

The state machine is the contract

Five states cover almost every job: queued (accepted, not started), running (a worker owns it), succeeded, failed, cancelled. Three are terminal; the contract says which, because "terminal" is what tells a client to stop polling. Transitions are explicit: queued → running → succeeded|failed, queued|running → cancelled, and — the one most contracts forget — whether failed can go back to queued on an automatic retry. If the worker retries transient failures internally, the client should see running with an attempt counter, not a failed that later flips back; a state that un-terminates is a state machine that lied (see Resources Have State Machines).

Progress is a field, not a state: progress: { "done": 4200, "total": 10000, "message": "rendering pages" }, updated at a documented cadence and explicitly *advisory* — the client may render it, may not branch on it. Cancellation is a request, not a command: POST /jobs/{id}/cancel moves the job to cancelling (or records a cancel flag) and the worker acknowledges at its next checkpoint; the contract states that a job may still complete after a cancel was accepted, and what the client sees then. A cancelled state that the server cannot actually guarantee is a promise the contract should not make.

Results and errors are typed. Small results inline (result: {…}), large ones as a URL with an expiry, errors as the same The Error Model: Structure Over Apology the synchronous API uses — code, message, retryable — so a client's failure handling does not fork for async operations. Retention is a number: "job resources and results are readable for 7 days after reaching a terminal state; afterwards GET returns 404 with code JOB_EXPIRED". Consumers plan storage and reconciliation around that number; without it they plan around a guess.

A job resource that answers every question a client will ask
Request
GET /jobs/job_5k2
Authorization: Bearer …
Response
HTTP/1.1 200 OK
Cache-Control: no-store
Retry-After: 5

{
  "id": "job_5k2",
  "kind": "report.generate",
  "status": "running",            // queued | running | succeeded | failed | cancelled
  "terminal": false,
  "attempt": 2,
  "progress": { "done": 4200, "total": 10000, "message": "rendering pages" },
  "created_at": "2026-08-25T10:00:00Z",
  "started_at": "2026-08-25T10:00:03Z",
  "expires_at": "2026-09-01T10:00:00Z",
  "result": null,                  // { "url": "…", "url_expires_at": "…" } when succeeded
  "error": null,                   // { "code": "SOURCE_UNAVAILABLE", "message": "…", "retryable": true } when failed
  "links": { "cancel": "/jobs/job_5k2/cancel", "events": "/jobs/job_5k2/events" }
}

Creating jobs safely, and knowing when they finish

Job creation is a non-idempotent POST with a side effect that costs money or time, so it is exactly the case Idempotency Keys: The Mechanism exists for: the client sends Idempotency-Key, a retried create returns the *same* job with 200 instead of a second 202, and the key is scoped to the caller and expires after the retention window. Without this, a timeout on the create — the most common failure, because clients wait for a 202 they expect to be instant — spawns a duplicate job, a duplicate report, and a duplicate invoice line for the compute.

Learning that a job finished is its own contract (How the Client Learns the Job Finished): polling with Retry-After and backoff as the always-available baseline, a webhook for servers, SSE on /jobs/{id}/events for browsers and agents that want the progress stream. The job resource stays the source of truth whichever channel delivered the news — a client that receives a "succeeded" webhook still fetches the job to get the result, so a duplicated or reordered notification cannot mislead it.

Listing jobs (GET /jobs?status=running&kind=report.generate) turns the pattern into an operational surface: consumers reconcile what they started against what exists, operators see the backlog. It is a collection, so it is bounded and cursor-paged (Unbounded Collections: The Anti-Pattern With a Fuse) and filterable on the fields the state machine defines.

The job contract, as a policy document
Create     POST /reports  (Idempotency-Key required)  → 202 + Location: /jobs/{id}
           retry with same key → 200 + same job
States     queued → running → succeeded | failed
           queued | running → cancelling → cancelled
           terminal: succeeded, failed, cancelled  (never un-terminate)
           transient worker retries: stay "running", increment attempt
Progress   advisory; done/total/message; updated ≤ every 5 s
Cancel     POST /jobs/{id}/cancel → 202; job may still complete; see final status
Result     inline ≤ 64 KB, else { url, url_expires_at }
Error      same error model as sync API; includes retryable
Retention  7 days after terminal state; then 404 JOB_EXPIRED
Notify     poll (Retry-After) | webhook job.completed | SSE /jobs/{id}/events
List       GET /jobs?status=&kind=&cursor=&limit=  (max 100)

Key points

  • POST creates a job resource and returns 202 immediately; the work happens elsewhere and the job is the client's handle on it.
  • The state set, its terminal members and its legal transitions are contract — a state that un-terminates is a lie the client cannot recover from.
  • Progress is advisory, cancellation is a request that may not win, results and errors are typed, and retention is a number.
  • Job creation carries an Idempotency-Key so the create's own timeout cannot spawn duplicate work.
  • The job resource remains the source of truth however completion is delivered; notifications point at it rather than replacing it.

Progressive depth

Overview

When the work takes longer than a request should, the request creates a job and returns immediately. The job is a resource the client can look at, wait on, cancel and eventually read the result from.

Practical

Return 202 with Location: /jobs/{id}. Give the job status from a documented set with explicit terminal states, progress, result or error, and expires_at. Require an Idempotency-Key on create. Return Retry-After on non-terminal reads and offer a webhook or SSE stream for completion (How the Client Learns the Job Finished).

Advanced

Model worker retries as running + attempt, never as a failed that un-terminates. Make cancel a request with a cancelling state and a documented "may complete anyway" rule. Version the status enum with terminal: boolean so new states are additive. Keep /jobs bounded and filterable for reconciliation; scope idempotency keys per caller and expire them with retention.

Internals

Behind the resource: a job row written in the same transaction as the outbox/enqueue so a crash cannot lose the job or run it without a record; a queue with visibility timeouts that re-deliver on worker death (which is why attempt exists); result blobs in object storage with lifecycle rules that implement retention; and a sweeper that expires rows so GET can return JOB_EXPIRED rather than 500. See Background Jobs and Workers and Message Queues.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → API: converts POST /generate-report to return 202 { "job_id": … } and a GET /jobs/{id} that returns only { "status": "pending" | "done" }.
  2. 2
    Client → API: polls every second with no Retry-After guidance; a thousand clients turn the job table into the hottest query in the system.
  3. 3
    Worker → job: a transient failure is written as failed; the queue retries and later writes done; the client that gave up on failed never fetches the result.
  4. 4
    Client → API: the create times out at the gateway; the client retries; two jobs run, two reports are generated, and the customer is billed twice.
  5. 5
    Client → result: stores the result URL and fetches it a week later; the blob was deleted after three days and nobody wrote that down.
What breaks
  • Clients cannot distinguish transient from final failure, so they either give up on retryable jobs or retry succeeded ones.
  • Duplicate jobs from retried creates waste compute and, for anything billed or side-effecting, duplicate the effect.
  • Undocumented retention leaves consumers with dangling ids and 404s in their own data.
  • Uncontrolled polling converts an async pattern designed to save connections into a read storm on the job store.

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
  • • Define the state machine explicitly: state set, terminal states, legal transitions, and how internal retries appear (stay `running`, bump `attempt`).
  • • Require an `Idempotency-Key` on job creation; replay the same job for the same key within the retention window.
  • • Return `Retry-After` on every non-terminal `GET`, and document the polling contract alongside webhook and SSE alternatives.
  • • Type the result (inline or URL with expiry) and reuse the synchronous error model with a `retryable` flag.
  • • State retention as a number and the error code returned after expiry; make `/jobs` a bounded, filterable collection.
Observe in production
  • • Job-store read QPS far above job creation rate is uncontrolled polling; per-caller poll intervals identify who ignores `Retry-After`.
  • • Jobs whose status transitions out of a terminal state (visible in an audit of status history) reveal a state machine the contract does not describe.
  • • Duplicate jobs with identical parameters within seconds from one caller are missing idempotency keys on create.
  • • Queue age (time in `queued`) and `running` duration percentiles are the SLO signals; a growing `queued` age is the backlog consumers will ask about.
Evolve without breaking
  • • New states are breaking for clients that switch on status exhaustively — document the unknown-state rule ([[enum-evolution]]) and add `terminal: boolean` so clients branch on that instead.
  • • Adding progress, events streams, or a cancel endpoint is additive; changing retention downward or making the result URL-only where it was inline is breaking.
  • • Introducing job priorities or scheduled starts are new request fields with defaults; the state machine absorbs them as a `scheduled` pre-state only if the terminal set is untouched.
What it costs
  • • Two round trips minimum (create, then fetch) where a synchronous call was one; small, fast operations should stay synchronous.
  • • A job store, a queue, workers and a retention sweeper are real infrastructure to run, monitor and pay for.
  • • Cancellation as a request rather than a guarantee is honest but forces clients to handle "cancelled but completed anyway".
  • • Exposing `attempt` and progress invites clients to depend on advisory fields; the contract must keep saying they are advisory.

Misconceptions

Claim
“Async just means return 202 and let the client poll.”
Reality
That is the transport. The contract is the state machine, the idempotent create, the typed result and error, the retention period, and the polling/notification guidance. Without those, each client implements a different job model against the same endpoint.
Claim
“A `failed` job can be retried by the system and become `succeeded` later.”
Reality
Then failed was not terminal and the client that stopped polling at failed never sees the result. Internal retries stay in running with an attempt counter; failed means the system has given up.
Claim
“Cancel means the work stops.”
Reality
Cancel means the request to stop was recorded. The worker checks at its next checkpoint; a job can finish after a cancel was accepted. The contract says so and tells the client to read the final status rather than assume.

Apply it