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:
| Header | Value here | Meaning |
|---|---|---|
Location | /api/jobs/job_1009sign in to see | Where to ask about this job. A relative URL, resolved against the API's address |
Retry-After | 2 | Seconds 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:
{
"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
}The fields are typical of a well-designed job resource:
statusmoves fromqueuedtorunningand ends ascompleted,failedorcancelled. The last three are terminal: once there, a job never changes again.progressruns from 0 to 100 while the job works.startedAtandfinishedAtstay null until those moments happen.resultUrlappears only when there's a result to fetch;erroronly 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:
+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":{...}}- The job queued for about two seconds before it started, as the first
Retry-Aftersuggested. - Asking for the result too early got
409 Conflict, a message saying why, and its ownRetry-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. - While the job was running, every status response carried
Retry-After: 1, and progress only went up. - Once completed, the header disappeared,
resultUrlappeared, and the result was available.
A client that waits properly
Three rules make a polling client well behaved:
- Wait as long as
Retry-Aftersays, 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
runningdoesn't keep the client waiting forever. - Treat every terminal status as an answer, not just
completed.
// 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 ?? "");
}$ 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}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.