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 shape of the pattern
- The client
POSTs the job. The server validates it, enqueues it, and immediately returns202 Acceptedwith a URL where the status lives. - The client polls that status URL until the job is
done(orfailed). - 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.
Step 1: Accept the job
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.
Step 2: Expose a status endpoint
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.
Step 3: Poll from the client
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() < deadline) {
const res = await fetch(url, { redirect: "manual" });
if (res.status === 303) {
const resultUrl = res.headers.get("location");
return fetch(resultUrl).then((r) => 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}`);
Practical details that bite you
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.
Closing
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.
Top comments (0)