Work That Outlives the Request
Return 202 with a Location pointing at a status resource, and let the client poll or be called back instead of holding a connection.
POST /exports -> 202 Accepted, Location: /exports/{id}, Retry-After: 5Some work does not fit inside a request. A CSV export of two million rows, a video transcode, a credit check against a third party that takes ninety seconds. Holding the connection open collects every failure at once: a load balancer idle timeout, a proxy that buffers the whole body, a client retry that starts the work a second time, and a worker pinned for the duration.
202 and a status resource
Validate synchronously — return 400 now if the request is wrong — then enqueue the work and reply 202 Accepted with a Location header pointing at a status resource and a Retry-After suggesting a polling interval. The status resource answers 200 with a document the client can read: a status from a documented set (pending, running, succeeded, failed, canceled), a created and a last-updated timestamp, optional progress, and a problem+json error object when it failed. A dedicated status endpoint beats polling the eventual resource URL, where 404 means both "not ready" and "bad id".
How the client learns the result
On completion, return the result inline or redirect to it with 303 See Other — not 302, which does not guarantee the client switches to GET, so some clients replay the POST and start the job again. Polling always works; a webhook callback is lower latency but requires the client to run a reachable, signed endpoint.
Give the status resource a retention window, expose DELETE on it for cancellation, and accept an Idempotency-Key on submit — the client cannot distinguish a lost 202 from a request that never arrived.
Example
// routes/exports.js
import express from 'express';
const router = express.Router();
// 1. Accept: validate now, do the work later.
router.post('/exports', idempotent(async (req) => {
const { report, from, to } = req.body;
if (!REPORTS.has(report)) throw new ApiError(400, 'unknown-report');
const job = await db.one(
`INSERT INTO export_jobs (account_id, report, range_from, range_to, status)
VALUES ($1, $2, $3, $4, 'pending') RETURNING id, status, created_at`,
[req.principal.accountId, report, from, to]);
await queue.enqueue('build-export', { jobId: job.id });
return { status: 202, body: job, location: `/exports/${job.id}` };
}));
// The middleware sets the headers 202 requires:
// res.location(url) === res.set('Location', url)
// Retry-After tells a polling client how often is reasonable.
// 2. Status: cheap, cacheable for a second, and always 200 while running.
router.get('/exports/:id', async (req, res) => {
const job = await exports_.forAccount(req.principal.accountId, req.params.id);
if (!job) return res.status(404).end();
if (job.status === 'succeeded') {
// 303 makes the client GET the result, whatever method got it here.
return res.status(303).location(job.result_url).end();
}
res.set({ 'Retry-After': '5', 'Cache-Control': 'no-store' });
res.json({
id: job.id,
status: job.status, // pending | running | failed | canceled
createdAt: job.created_at,
lastUpdatedAt: job.updated_at,
percentComplete: job.percent ?? undefined,
// On failure, the same problem+json shape as a synchronous error.
error: job.status === 'failed' ? job.error : undefined,
});
});
// 3. Cancel: the status resource is a resource, so DELETE means something.
router.delete('/exports/:id', async (req, res) => {
const ok = await exports_.requestCancel(req.principal.accountId, req.params.id);
res.status(ok ? 202 : 409).end();
});
export default router;When to use it
- A report endpoint that currently times out at the load balancer after 60 seconds
- Replacing a long-polling call that a mobile network keeps dropping
- Giving clients a way to cancel an expensive job they started by mistake
- Deciding whether an integration should poll a status URL or receive a webhook
More examples
The whole exchange on the wire
Everything the client needs is in headers it already understands, so curl, a browser and a generated SDK all behave. The signed, expiring file URL matters: it keeps a multi-gigabyte download off your application tier entirely, and it is why the status endpoint redirects rather than streaming.
POST /exports HTTP/1.1
Idempotency-Key: 4e7f2c1a-...
{"report": "transactions", "from": "2026-01-01", "to": "2026-09-30"}
HTTP/1.1 202 Accepted
Location: /exports/exp_01HV8Z
Retry-After: 5
{"id": "exp_01HV8Z", "status": "pending", "createdAt": "2026-10-02T09:14:00Z"}
GET /exports/exp_01HV8Z # 5s later
HTTP/1.1 200 OK
Retry-After: 5
{"id": "exp_01HV8Z", "status": "running", "percentComplete": 38,
"lastUpdatedAt": "2026-10-02T09:14:06Z"}
GET /exports/exp_01HV8Z # once more
HTTP/1.1 303 See Other
Location: https://files.example.com/exp_01HV8Z.csv?sig=...&expires=1790000000
GET /exports/exp_01HV8Z # after the retention window
HTTP/1.1 404 Not FoundA polling client that does not hammer you
Reach for this inside an SDK so callers get one awaitable call without you holding a socket open. The small random addition is deliberate: ten workers that all submitted at the same moment would otherwise poll in lockstep forever, turning a status endpoint into your most-requested route.
export async function awaitJob(statusUrl, { timeoutMs = 600_000 } = {}) {
const deadline = Date.now() + timeoutMs;
let wait = 1000;
while (Date.now() < deadline) {
// redirect: 'manual' so we can see the 303 instead of silently following it.
const res = await fetch(statusUrl, { redirect: 'manual' });
if (res.status === 303) return res.headers.get('Location');
if (res.status === 404) throw new Error('job expired or never existed');
const job = await res.json();
if (job.status === 'failed') throw new JobFailed(job.error);
if (job.status === 'canceled') throw new Error('job canceled');
// Prefer the server's own pacing; otherwise back off, capped.
const hinted = Number(res.headers.get('retry-after'));
wait = Number.isFinite(hinted) && hinted > 0
? hinted * 1000
: Math.min(wait * 2, 30_000);
await new Promise(r => setTimeout(r, wait + Math.random() * 250));
}
throw new Error('timed out waiting for job');
}The worker that keeps the status resource honest
The status resource is only as truthful as the worker that updates it. The two states teams forget are 'running' (so a client can tell a slow job from a lost one) and a persisted failure — without the latter, a crashed worker leaves a job that polls as pending until the retention sweep deletes it.
queue.handle('build-export', async ({ jobId }) => {
// Claim it: the status visible to the client must change as work progresses.
const claimed = await db.query(
`UPDATE export_jobs SET status='running', updated_at=now()
WHERE id=$1 AND status='pending'`, [jobId]);
if (claimed.rowCount === 0) return; // redelivery, or already canceled
try {
const url = await buildCsv(jobId, async (percent) => {
// Progress is a write the client can see; throttle it, it is not free.
await db.query(`UPDATE export_jobs SET percent=$2, updated_at=now() WHERE id=$1`,
[jobId, percent]);
});
await db.query(
`UPDATE export_jobs SET status='succeeded', result_url=$2, updated_at=now()
WHERE id=$1`, [jobId, url]);
} catch (err) {
// Persist the failure in problem+json shape so the status endpoint can
// serve it verbatim. A job that dies without writing this looks 'running'
// forever, which is the worst state for a caller to be in.
await db.query(
`UPDATE export_jobs SET status='failed', error=$2, updated_at=now() WHERE id=$1`,
[jobId, { type: 'https://api.example.com/problems/export-failed',
title: 'Export failed', status: 500, retryable: true }]);
throw err; // let the queue count the attempt
}
});
Discussion