TestingAPIs is still being built. If something breaks, email support@testingapis.com.

Polling a job: 202 Accepted done right

Location, Retry-After, status fields, and a client that waits politely.

The job pattern is the most common way APIs handle slow work, and the easiest async pattern to get mostly right and slightly wrong. The client starts the work, gets an address to check, and checks until it's done. This lesson goes through each part of the exchange on the jobs API, then builds a client that waits the way the server asks it to.

202 Accepted, Location and Retry-After

Starting a job with POST /api/jobssign in to see returns 202 Accepted. RFC 9110 defines 202 for exactly this: the request was accepted for processing, but processing hasn't finished, and may not even succeed. Two headers carry the instructions:

HeaderValue hereMeaning
Location/api/jobs/job_1009sign in to seeWhere to ask about this job. A relative URL, resolved against the API's address
Retry-After2Seconds to wait before the first check

Clients should take the job URL from Location rather than build it from the ID. Then the server is free to move status URLs later without breaking anyone. The body repeats the same information as statusUrl.

The status resource

GET /api/jobs/job_1009sign in to see straight after starting:

Response (200 OK)
{
  "id": "job_1009",
  "type": "report",
  "status": "queued",
  "progress": 0,
  "durationSeconds": 4,
  "createdAt": "2026-10-11T12:33:48Z",
  "startedAt": null,
  "finishedAt": null,
  "statusUrl": "/api/jobs/job_1009",
  "resultUrl": null,
  "error": null
}
This example uses endpoints for signed-in members.Sign inCreate a free account

The fields are typical of a well-designed job resource:

  • status moves from queued to running and ends as completed, failed or cancelled. The last three are terminal: once there, a job never changes again.
  • progress runs from 0 to 100 while the job works.
  • startedAt and finishedAt stay null until those moments happen.
  • resultUrl appears only when there's a result to fetch; error only when the job failed.

One job, from start to finish

Here is a 4-second report job, checked every half second, with the status and headers of each response. Every other check while it ran is left out to keep it short:

Timeline
+0.3s  POST /api/jobs           202  Retry-After: 2   status=queued
+0.3s  GET  .../result          409  Retry-After: 1   "Job is still queued (0%). Poll /api/jobs/job_1009 and retry."
+0.4s  GET  /api/jobs/job_1009  200  Retry-After: 1   status=queued     progress=0
+2.5s  GET  /api/jobs/job_1009  200  Retry-After: 1   status=running    progress=7
+3.6s  GET  /api/jobs/job_1009  200  Retry-After: 1   status=running    progress=34
+4.7s  GET  /api/jobs/job_1009  200  Retry-After: 1   status=running    progress=61
+5.8s  GET  /api/jobs/job_1009  200  Retry-After: 1   status=running    progress=88
+6.4s  GET  /api/jobs/job_1009  200                   status=completed  progress=100
+6.5s  GET  .../result          200                   {"jobId":"job_1009","type":"report","result":{...}}
This example uses endpoints for signed-in members.Sign inCreate a free account
  1. The job queued for about two seconds before it started, as the first Retry-After suggested.
  2. Asking for the result too early got 409 Conflict, a message saying why, and its own Retry-After. It didn't get a 404, which would wrongly suggest the result will never exist, or a 200 with an empty body, which a careless client would treat as the real result.
  3. While the job was running, every status response carried Retry-After: 1, and progress only went up.
  4. Once completed, the header disappeared, resultUrl appeared, and the result was available.

A client that waits properly

Three rules make a polling client well behaved:

  • Wait as long as Retry-After says, and back off when there's no hint, so you don't hammer the server.
  • Give up after a deadline, so a job stuck in running doesn't keep the client waiting forever.
  • Treat every terminal status as an answer, not just completed.
JavaScript
// poll.mjs: start a job and wait for it, the way a client should
const BASE = "https://api.testingapis.com";
const t0 = Date.now();
const log = (...a) => console.log(`+${((Date.now() - t0) / 1000).toFixed(1)}s`, ...a);

async function startJob(body) {
  const res = await fetch(`${BASE}/api/jobs`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (res.status !== 202) throw new Error(`expected 202, got ${res.status}`);
  const location = res.headers.get("Location");
  log("202 Accepted, Location:", location, "Retry-After:", res.headers.get("Retry-After"));
  return { statusUrl: new URL(location, BASE), firstWait: Number(res.headers.get("Retry-After") ?? 1) };
}

async function waitForJob(statusUrl, { firstWait = 1, maxWaitSeconds = 60 } = {}) {
  const deadline = Date.now() + maxWaitSeconds * 1000;
  let wait = firstWait;
  while (true) {
    await new Promise((r) => setTimeout(r, wait * 1000));
    const res = await fetch(statusUrl);
    const job = await res.json();
    log(`status=${job.status} progress=${job.progress}`);
    if (!["queued", "running"].includes(job.status)) return job;
    if (Date.now() > deadline) throw new Error(`job still ${job.status} after ${maxWaitSeconds} s`);
    // The server says when to come back; without a hint, back off up to 5 seconds
    wait = Number(res.headers.get("Retry-After")) || Math.min(wait * 2, 5);
  }
}

const { statusUrl, firstWait } = await startJob({ type: "report", durationSeconds: 3 });
const job = await waitForJob(statusUrl, { firstWait });

if (job.status === "completed") {
  const result = await (await fetch(new URL(job.resultUrl, BASE))).json();
  log("result:", result.result.reportName, "totals:", JSON.stringify(result.result.totals));
} else {
  log("job ended as", job.status, job.error ?? "");
}
This example uses endpoints for signed-in members.Sign inCreate a free account
Output
$ node poll.mjs
+0.3s 202 Accepted, Location: /api/jobs/job_1013 Retry-After: 2
+2.3s status=running progress=2
+3.4s status=running progress=37
+4.4s status=running progress=72
+5.5s status=completed progress=100
+5.6s result: Monthly Sales Summary totals: {"revenue":323986.45,"orders":3884}
This example uses endpoints for signed-in members.Sign inCreate a free account

Four status checks for a job of about five seconds: one after the initial two-second wait, then one a second, as the server asked. A client that polled as fast as it could would have made dozens of requests for the same answer. Multiply that by every user of an app and polling becomes a load problem, which is why Retry-After exists.

Exercise

Change the client to start a job of type fail, and check it reports the failure and the error message rather than trying to fetch a result. Then set maxWaitSeconds to 2 with a 10-second job, and check it gives up with a clear message. Finally, remove the Retry-After handling so it always waits 0.1 seconds, and count how many requests the same job now takes.

Practise and go further