Advanced HTTP Patterns · Lesson 22 of 30
Asynchronous REST APIs: 202 Accepted and Long-Running Jobs
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
- POST /api/reports202 Accepted, Location: /api/jobs/job_1
- GET /api/jobs/job_1200 {status: "running", progress: 40}, Retry-After: 1
- GET /api/jobs/job_1200 {status: "succeeded"} with a link to the result
- 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);
}
$ 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 Acceptedmeans "I've taken this on, it's not done". TheLocationheader 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-Aftertells 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?
| Polling | Webhooks / callbacks | |
|---|---|---|
| How | client asks repeatedly | server calls the client's URL when done |
| Client needs | nothing special – works everywhere, even browsers | a public HTTPS endpoint |
| Efficiency | wasted requests while waiting | one notification |
| Reliability | simple; client controls it | needs 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
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.