Long-Running Operations
AdvancedAnything slower than a few seconds should not be an HTTP request. Accept the work, return an id, and report progress separately.
Overview
A request that takes ninety seconds fails in ways that have nothing to do with your code: a platform gateway times out at thirty, a proxy closes an idle connection, a mobile network drops, the user navigates away. Generating a report, processing a video, running an AI batch, importing a spreadsheet — all of these need the same shape. Accept the job, return 202 with an id immediately, do the work elsewhere, and give the client a way to learn the outcome. Getting this right is a common system-design question precisely because the naive version fails only in production.
Accept and Report
The job pattern, and what the endpoints look like.
# Do NOT do the work in the request
@router.post("/reports")
async def create_report(body: ReportIn, user = Depends(current_user)):
job = await db.create_job(kind="report", user_id=user.id,
params=body.dict(), status="queued")
await queue.enqueue("generate_report", job.id) # Celery, RQ, Arq…
return JSONResponse(202, {"job_id": job.id,
"status_url": f"/jobs/{job.id}"})
@router.get("/jobs/{job_id}")
async def job_status(job_id: str, user = Depends(current_user)):
job = await db.get_job(job_id, user_id=job_id and user.id) # ownership
if not job: raise HTTPException(404)
return {"status": job.status, # queued | running | done | failed
"progress": job.progress, # 0-100, if you can compute it
"result_url": job.result_url, # when done
"error": job.error} # when failed
# The worker updates progress as it goes, so the UI can show
# something honest rather than an indeterminate spinner for a minute.
# Jobs must be idempotent and retryable: a worker can die mid-task
# and the queue will redeliver. Design for the task running twice.Polling, SSE or Webhooks
Three ways to learn the outcome, each suited to a different case.
// POLLING — simplest, works everywhere, survives a refresh
useQuery({
queryKey: ['job', jobId],
queryFn: () => api(`/jobs/${jobId}`),
refetchInterval: (q) =>
['done', 'failed'].includes(q.state.data?.status) ? false : 2000,
})
// Back off as it goes: 1s, 2s, 5s, 10s. A fixed 500ms interval on a
// ten-minute job is 1,200 pointless requests per user.
// SSE — push, one direction, reconnects itself. Best for progress.
const es = new EventSource(`/jobs/${jobId}/stream`)
es.onmessage = e => setProgress(JSON.parse(e.data))
es.addEventListener('done', () => es.close())
// Watch out for platform buffering: some proxies hold SSE output
// until the response closes, which defeats the point.
// WEBHOOKS — for server-to-server, when the caller is not a browser
// (a payment gateway telling you a charge succeeded). Verify the
// signature, respond 200 fast, process asynchronously, and expect
// duplicates — so handling must be idempotent.
// Choosing: polling for most job UIs, SSE when live progress is the
// point, webhooks between servers. WebSockets only if the client
// also needs to send continuously.The UI Side
What the user experiences while a job runs, including leaving and coming back.
// The job id belongs in the URL or in storage, so a refresh or a
// closed laptop does not lose the work:
navigate(`/reports/${jobId}`)
// A user must be able to leave. The job continues on the server; the
// page just stops watching. Tell them how they will find out:
"We are generating your report. You can close this page — we will
email you when it is ready."
// Show real progress where you can, and a phase where you cannot:
// "Processing 340 of 1,200 rows" > a spinner
// "Analysing submissions…" > "Loading…"
// Failure needs to be actionable and specific:
{job.status === 'failed' && (
<ErrorState message={job.error} onRetry={() => retry(job.id)} />
)}
// And bound it: a job stuck in "running" for an hour is a dead
// worker, not slow work. Time jobs out server-side and mark them
// failed, or the UI polls forever.
// Cancellation: let the user stop a long job, and have the worker
// check a cancellation flag between steps.Key Points to Remember
- 1Work longer than a few seconds must not run inside an HTTP request — gateways and proxies will cut it off
- 2Accept the job, return 202 with an id and a status URL, and process it in a worker
- 3Workers can be redelivered, so every job must be idempotent and safe to run twice
- 4Poll with increasing intervals for most job UIs, SSE when live progress matters, webhooks between servers
- 5Put the job id in the URL so the user can leave and come back, and time out stuck jobs or the UI polls forever
Interview Questions
Sign in to ask AriaWhy should a ninety-second operation not be a single HTTP request?
How would you report progress on a long job to the browser?
Why must a queued job be idempotent?
Ask Aria about Long-Running Operations
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.