DEV Community

Mean for APIKumo

Posted on

Async APIs: The 202 Accepted + Polling Pattern for Long-Running Operations

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

  1. The client POSTs the job. The server validates it, enqueues it, and immediately returns 202 Accepted with a URL where the status lives.
  2. The client polls that status URL until the job is done (or failed).
  3. 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" });
});
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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)