LearnFrenzy
Sign in Take a free test

Advanced HTTP Patterns · Lesson 22 of 30

Asynchronous REST APIs: 202 Accepted and Long-Running Jobs

4 min read Updated Nov 8, 2026 Saurabh Samir

Some work takes too long for one HTTP request: generating a 500-page report, importing a million rows, transcoding a video. Holding the connection open invites timeouts from load balancers and impatient clients. The standard REST answer is the asynchronous request–reply pattern: accept the job immediately with 202 Accepted, give the client a job resource to check, and point to the result when it's ready.

In this lesson

  • 202 Accepted and a job resource
  • Polling politely with Retry-After
  • A complete client that waits for the result
  • Polling vs webhooks

Async request–reply

  1. POST /api/reports202 Accepted, Location: /api/jobs/job_1
  2. GET /api/jobs/job_1200 {status: "running", progress: 40}, Retry-After: 1
  3. GET /api/jobs/job_1200 {status: "succeeded"} with a link to the result
  4. GET /api/reports/rep_1200 – the finished report

The server

// server.js
import express from 'express';

const app = express();
app.use(express.json());

const jobs = new Map();
const reports = new Map();

app.post('/api/reports', (req, res) => {
  const id = `job_${jobs.size + 1}`;
  const job = { id, status: 'queued', progress: 0, resultUrl: null };
  jobs.set(id, job);

  // simulate a background worker (a queue like BullMQ, SQS or RabbitMQ in production)
  let step = 0;
  const timer = setInterval(() => {
    step++;
    job.status = 'running';
    job.progress = Math.min(step * 25, 100);
    if (step === 4) {
      clearInterval(timer);
      reports.set('rep_1', { id: 'rep_1', type: req.body.type, rows: 18250 });
      Object.assign(job, { status: 'succeeded', resultUrl: '/api/reports/rep_1' });
    }
  }, 150);

  res.status(202).location(`/api/jobs/${id}`).set('Retry-After', '1').json(job);
});

app.get('/api/jobs/:id', (req, res) => {
  const job = jobs.get(req.params.id);
  if (!job) return res.status(404).json({ error: 'Job not found' });
  if (job.status !== 'succeeded' && job.status !== 'failed') res.set('Retry-After', '1');
  res.json(job);
});

app.get('/api/reports/:id', (req, res) => {
  const report = reports.get(req.params.id);
  return report ? res.json(report) : res.status(404).json({ error: 'Report not found' });
});

app.listen(process.env.PORT ?? 3000);

A client that waits for the result

// client.js
const API = 'http://localhost:3000';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const start = await fetch(`${API}/api/reports`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ type: 'annual-sales' }),
});
console.log(`POST /api/reports → ${start.status}, Location: ${start.headers.get('Location')}`);

let job = await start.json();
console.log(`  status: ${job.status}`);
const jobUrl = API + start.headers.get('Location');
const deadline = Date.now() + 10_000;                         // never poll forever

while (job.status === 'queued' || job.status === 'running') {
  if (Date.now() > deadline) throw new Error('Gave up waiting');
  await sleep(200);                                          // real clients use Retry-After (seconds) and back off
  const previous = job.status;
  job = await (await fetch(jobUrl)).json();
  if (job.status !== previous) console.log(`  status: ${job.status}`);   // log each change once
}

if (job.status === 'succeeded') {
  const report = await (await fetch(API + job.resultUrl)).json();
  console.log('report ready:', report);
}

Real output of the client

$ node client.js
POST /api/reports → 202, Location: /api/jobs/job_1
  status: queued
  status: running
  status: succeeded
report ready: { id: 'rep_1', type: 'annual-sales', rows: 18250 }
  • 202 Accepted means "I've taken this on, it's not done". The Location header points at the job.
  • The job resource has a status (queued, running, succeeded, failed), progress if you can measure it, errors if it failed, and a link to the result.
  • Retry-After tells clients how often to poll; clients should also cap total waiting time.
  • Jobs should be cancellable (DELETE /api/jobs/{id}) and kept for a while after finishing so late pollers still get an answer.

Polling or webhooks?

PollingWebhooks / callbacks
Howclient asks repeatedlyserver calls the client's URL when done
Client needsnothing special – works everywhere, even browsersa public HTTPS endpoint
Efficiencywasted requests while waitingone notification
Reliabilitysimple; client controls itneeds retries, signatures and idempotent handlers

Many APIs offer both: polling as the reliable baseline, webhooks for efficiency (covered in the next chapter). For live progress in a browser, Server-Sent Events or WebSockets are another option.

Practice

Try it yourself

  1. What should the job resource look like when the report fails because the date range has no data?

    Show solution
    GET /api/jobs/job_7  →  200 OK
    {
      "id": "job_7",
      "status": "failed",
      "progress": 100,
      "error": { "type": "no-data", "detail": "No sales between 2025-01-01 and 2025-01-31" },
      "resultUrl": null
    }

    The job request itself succeeded (200); the job failed, which is described in the body.

Quick check

What does 202 Accepted promise?

Only that the request was accepted for processing – not that it succeeded.

How does the client find the job?

From the Location header (and the job body).

How should a client poll?

Respect Retry-After, back off, and stop after a deadline.

Summary

  • Long work: return 202 Accepted with a Location to a job resource.
  • The job resource reports status, progress, errors and a link to the result.
  • Clients poll with Retry-After and a deadline; webhooks can replace polling.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Search LearnFrenzy

Esc

Quick links