Async APIs: The 202 Accepted + Polling Pattern for Long-Running Operations
Source: Dev.to
Some API requests can’t finish in time for a single HTTP response. Generating a report, transcoding a video, running a batch import — these take seconds or minutes, far longer than any client should hold a connection open for. If you try to do this work inside a normal request, you’ll hit gateway timeouts, frustrated clients retrying half-finished jobs, and load balancers killing connections at 30 or 60 seconds. The fix is a well-established HTTP pattern: accept the work, hand back a receipt, and let the client poll for the result. Here’s how to build it properly. The client POSTs the job. The server validates it, enqueues it, and immediately returns 202 Accepted with a URL where the status lives. The client polls that status URL until the job is done (or failed). When complete, the status response points to the finished resource. The key detail most implementations get wrong: 202 does not mean “success.” It means “I accepted this and will work on it.” The actual outcome arrives later. import express from “express”; import { randomUUID } from “crypto”;
const app = express(); app.use(express.json()); const jobs = new Map(); // use Redis or a DB in production
app.post(“/v1/reports”, (req, res) => { const id = randomUUID(); jobs.set(id, { status: “pending”, createdAt: Date.now(), result: null });
// Kick off work without blocking the response processReport(id, req.body).catch((err) => { jobs.set(id, { status: “failed”, error: err.message }); });
res
.status(202)
.location(/v1/reports/${id})
.json({ id, status: “pending” });
});
Notice the Location header. It tells the client exactly where to look — no need to construct the URL itself. app.get(“/v1/reports/:id”, (req, res) => { const job = jobs.get(req.params.id); if (!job) return res.status(404).json({ error: “unknown job” });
if (job.status === “done”) {
// Redirect to the finished resource, or inline it
return res.status(303).location(/v1/reports/${req.params.id}/result).end();
}
// Still working: tell the client when to check again res.set(“Retry-After”, “5”); res.json({ id: req.params.id, status: job.status }); });
The Retry-After: 5 header is the polite, machine-readable way to say “check back in 5 seconds.” A 303 See Other on completion sends the client to the real result so the status URL and the result URL stay cleanly separated. Don’t hammer the server in a tight loop. Honor Retry-After and cap your wait: async function waitForJob(url, { timeoutMs = 120000 } = {}) { const deadline = Date.now() + timeoutMs;
while (Date.now() r.json());
}
if (res.status >= 400) throw new Error(Job failed: ${res.status});
const wait = Number(res.headers.get("retry-after") || 3) * 1000;
await new Promise((r) => setTimeout(r, wait));
} throw new Error(“Timed out waiting for job”); }
const { id } = await fetch(“/v1/reports”, { method: “POST”, headers: { “content-type”: “application/json” }, body: JSON.stringify({ range: “2026-Q1” }), }).then((r) => r.json());
const report = await waitForJob(/v1/reports/${id});
Make status checks idempotent and cheap. They’ll be called far more often than the job runs. A single key lookup, no recomputation. Return progress when you can. A { status: “running”, progress: 0.6 } field turns a black box into a progress bar. Expire finished jobs. Don’t keep job records forever. Give status URLs a TTL and return 410 Gone once cleaned up. Consider webhooks as an alternative. Polling is simple and firewall-friendly, but if the client can receive callbacks, a webhook on completion saves a lot of wasted requests. Many APIs offer both. The 202-and-poll pattern keeps long-running work off your request path without surprising clients. Get the status codes, the Location and Retry-After headers, and the polling discipline right, and async endpoints feel just as predictable as synchronous ones. Testing async flows is fiddly — you’re juggling a POST, repeated GETs, and timing-dependent state transitions. APIKumo makes this easier: you can chain the initial request and the polling calls in a single flow, capture the job ID into a variable with a post-processor, and replay the whole sequence whenever you change the endpoint — so verifying your long-running operations becomes a one-click check instead of a manual stopwatch exercise.