17.10. Long-Running Tasks
Table of Contents
Why Long-Running Tasks Are a Problem in Web Backends
In a web backend you usually handle HTTP requests. A user clicks a button, your server receives a request, does some work, and returns a response.
This flow breaks down when the work takes a long time, for example:
- Generating a large report (30 seconds)
- Processing a big CSV upload (minutes)
- Resizing hundreds of images
- Running a machine learning model
- Syncing data with an external API
If you try to do this work directly inside the HTTP request handler, several problems appear:
- The HTTP request may time out, usually around 30 to 60 seconds.
- The server worker is blocked while the work runs, so it cannot handle other requests efficiently.
- If the server process restarts, the work is lost halfway.
Long‑running tasks need a different approach.
Types of Long-Running Tasks
Long‑running tasks come in several patterns. It is useful to recognize them because each type might be handled a bit differently.
CPU-heavy tasks
These use a lot of CPU:
- Video encoding
- Complex report generation
- Data compression and encryption of large files
- Numeric simulations
They may block threads and reduce the number of requests your app can handle. You often want to move these away from your main web process to dedicated workers.
I/O-heavy tasks
These spend most of the time waiting on input and output:
- Downloading or uploading large files
- Calling slow external APIs
- Reading and writing many files
- Copying data between databases
Sometimes asynchronous I/O can help, but if a single operation takes a long time (for example a 5 minute external API call), you still want to move it to a background worker instead of blocking the request.
Batch processing tasks
These work on a lot of items as a group:
- Processing all pending invoices every night
- Reindexing a search engine
- Sending a newsletter to 50,000 users
- Cleaning up old database records
They are scheduled or triggered by some condition, and can take minutes or hours. They are usually run as background jobs or scheduled tasks, not during HTTP requests.
User-triggered long actions
The user starts an action that cannot finish quickly:
- “Export my data” button that creates an archive of all their content
- “Generate monthly statement” for a large account
- “Rebuild search index for my store”
Here you want:
- The HTTP request to respond quickly.
- The heavy work to continue in the background.
- A way for the user to check progress and download the result later.
Patterns for Handling Long-Running Tasks
You rarely want to keep a user waiting for a long‑running task to finish inside a single request. Instead, use patterns that separate the trigger from the execution.
Fire-and-forget (not recommended in most cases)
The simplest idea is to start a background thread or async task inside your web process and immediately return a response.
Example in Python using a thread:
from threading import Thread
def send_report_email(user_id):
# heavy work here
...
@app.post("/send-report")
def send_report(user_id: int):
Thread(target=send_report_email, args=(user_id,)).start()
return {"status": "scheduled"}Problems:
- If the process crashes or restarts, the task stops and is lost.
- Hard to monitor, retry, or schedule.
- Scaling across many servers becomes painful.
Useful only for very small projects or prototypes.
Important rule: For reliable long‑running tasks in production, do not rely on ad‑hoc threads or in-process background tasks. Use a proper job queue and workers.
Job queue and workers
A more robust pattern is:
- The HTTP request handler creates a job and pushes it to a queue.
- A separate worker process listens to the queue, pulls jobs, and executes them.
- The web process returns quickly, usually with a job id.
- The client can later check job status or see the result.
Typical components:
- Message broker / queue: Redis, RabbitMQ, SQS, etc.
- Worker framework: Celery, RQ, Dramatiq, etc.
Basic flow with a job queue:
Client -> API server -> enqueue job in Redis -> return job_id
|
Worker process
reads job from
Redis
|
executes
|
updates job status / resultPolling and callbacks
After you enqueue a long‑running job, you need a way to notify the user or client.
Two main options:
- Client polling
- Client receives a
job_id. - Client calls an endpoint like
GET /jobs/{job_id}every few seconds. - Backend returns status:
pending,running,failed,completed, and possibly progress. - Server callback / webhook
- Client gives you a callback URL.
- When the job finishes, your backend calls that URL with the result.
- Often used for server‑to‑server integrations.
Table comparison:
| Pattern | Pros | Cons |
|---|---|---|
| Polling | Easy to implement, no extra infra | Extra requests, status may be slightly stale |
| Callback | Instant notifications, fewer requests | Requires public endpoint and security checks |
Chunking work
Sometimes a single job is so big that you want to split it into smaller jobs.
For example:
- You need to process 1 million records.
- Instead of one huge job, create 1000 jobs, each handling 1000 records.
Advantages:
- Smaller jobs are easier to retry.
- Fewer timeouts in worker processes.
- Better parallelism across multiple workers.
You usually track an overall “parent” job that knows how many sub-jobs must finish.
Designing APIs Around Long-Running Tasks
As a backend developer you must design HTTP APIs that safely trigger and manage long‑running tasks.
Pattern: Accept-and-track
Step 1, the client sends a request to start the work.
Example:
POST /reports
Content-Type: application/json
{
"user_id": 123,
"month": "2026-05"
}Step 2, the backend:
- Validates the input.
- Creates a job record in the database, for example:
| id | type | status | created_at | progress | result_url |
|-----|-----------|----------|---------------------|----------|------------|
| 42 | "report" | "queued" | 2026-05-01 10:00:00 | 0 | null |
- Publishes a job to the queue with
job_id = 42. - Returns a
202 Acceptedresponse, not200 OK, to signal that the request is accepted but not completed.
Response:
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"job_id": 42,
"status": "queued",
"status_url": "/jobs/42"
}Step 3, the client polls:
GET /jobs/42Possible responses:
{
"job_id": 42,
"status": "running",
"progress": 37
}or
{
"job_id": 42,
"status": "completed",
"result_url": "/downloads/report-42.pdf"
}or
{
"job_id": 42,
"status": "failed",
"error": "Could not access data source"
}Why 202 Accepted is useful
When you cannot complete an operation in a single request, 202 Accepted is a clear signal:
- “We got your request.”
- “We will process it later.”
- “Here is where you can check status.”
This is more correct than returning 200 OK with an empty result for operations that are still running.
Important rule: For long‑running operations triggered over HTTP, prefer 202 Accepted plus a status endpoint instead of waiting for the full task to complete.
Simple example design
Imagine a “Generate purchase history CSV” feature:
- Endpoint to start:
POST /users/{id}/purchase-history/export - Endpoint to check status:
GET /exports/{export_id} - Endpoint to download result:
GET /exports/{export_id}/file(after completion)
Your backend:
- Creates an
Exportrecord with fields likeid,user_id,status,created_at,file_path. - Enqueues a job that contains
export_id. - Worker generates CSV, stores file, updates
Exportrecord withstatus = 'completed'andfile_path. - Download endpoint checks
statusand returns the file only when ready.
This pattern will appear again and again in real‑world backends.
Timeouts, Limits, and Reliability
Long‑running tasks are very sensitive to limits and failures. Design with them in mind from the start.
Know your time limits
Different parts of your stack may have limits:
- HTTP clients: can timeout if the server does not reply in time.
- Reverse proxy or load balancer: often has a maximum request time.
- Application server: may have worker timeout settings.
- Worker framework: often has maximum task runtime.
For example, a worker task might be killed if it runs longer than 10 minutes. You must tune these values realistically or split tasks into smaller parts.
If a task often approaches your time limit, consider:
- Breaking the work into smaller jobs.
- Introducing checkpoints so partially completed work is not lost.
- Optimizing the work itself.
Handling failures and retries
Long‑running tasks touch many external systems, so failures are common:
- Temporary network errors.
- External API rate limits.
- Database locks and deadlocks.
- Disk full or permission issues.
You rarely want to fail permanently on the first error. Instead you use retries, often with delay and backoff.
Common strategy:
- Try the task.
- If it fails with a transient error, schedule a retry after a delay.
- Increase the delay for each retry, for example 1s, 2s, 4s, 8s, etc.
Basic exponential backoff formula:
$$
\text{delay}_n = \text{base} \times 2^{n-1}
$$
Example: base = 1 second.
- First retry: $1 \times 2^{0} = 1$ second
- Second retry: $1 \times 2^{1} = 2$ seconds
- Third retry: $1 \times 2^{2} = 4$ seconds, and so on.
Important rule: Long‑running tasks should be idempotent or safely retryable. A retry must not create duplicate data or corrupt state.
Idempotency usually means:
- Using unique identifiers for operations.
- Checking if the work was already done before doing it again.
- Designing database operations so duplicate calls have no harmful effect.
Progress tracking
For very long tasks, users want to see progress, not only “running”.
Simple progress tracking strategies:
- Percentage complete, for example from 0 to 100.
- Counts, for example “253 / 1000 records processed”.
- Status phases, like
queued,preparing,processing,finalizing,completed.
In code, you might store progress in a database table or cache:
| job_id | status | processed | total | last_update |
|---|---|---|---|---|
| 42 | running | 253 | 1000 | 2026-05-01 10:05:00 |
Workers update this record periodically. The status endpoint reads it and returns structured data to the client.
Example: End-to-End Flow for a Long-Running Task
Let us put everything together in an example scenario.
Goal: User uploads a big CSV file with 100,000 rows. Backend should import and validate the data. This may take 5 minutes.
Step 1: User uploads CSV
- Request:
POST /importswith CSV file. - Backend:
- Saves file to temporary storage.
- Creates an
ImportJobrecord:
| id | user_id | status | file_path | progress | total_rows |
|----|---------|----------|--------------------|----------|-----------|
| 7 | 123 | queued | /tmp/import7.csv | 0 | null |
- Enqueues a job
import_csv(job_id=7). - Returns:
{
"job_id": 7,
"status": "queued",
"status_url": "/imports/7"
}Step 2: Worker runs the job
Pseudo code:
def import_csv(job_id: int):
job = get_job(job_id)
job.status = "running"
save_job(job)
with open(job.file_path) as f:
rows = list(read_rows(f))
job.total_rows = len(rows)
save_job(job)
for index, row in enumerate(rows, start=1):
try:
process_row(row)
except ValidationError as e:
log_error(job_id, row, str(e))
# update progress every 100 rows to reduce writes
if index % 100 == 0 or index == job.total_rows:
job.progress = index
save_job(job)
job.status = "completed"
save_job(job)Step 3: Client polls for status
Every few seconds:
GET /imports/7Possible responses:
While running:
{
"job_id": 7,
"status": "running",
"processed": 4200,
"total": 100000
}When finished:
{
"job_id": 7,
"status": "completed",
"processed": 100000,
"total": 100000,
"errors_url": "/imports/7/errors"
}If something fails:
{
"job_id": 7,
"status": "failed",
"error": "Database is temporarily unavailable. Please retry later."
}The web server remains responsive during the whole 5 minute import, because the heavy work runs in worker processes.
Practical Tips and Common Pitfalls
Tips
- Keep requests short: Design HTTP handlers to do validation and job creation only. Offload heavy work to workers.
- Use clear job statuses: At least
queued,running,completed,failed, optionallycanceled. - Store metadata: Keep creation time, last update, and owner (user id) with each job.
- Limit concurrency: Avoid starting unlimited long‑running tasks at once. Control the number of worker processes and queues.
- Log well: Log start, completion, and failures of every long‑running task with identifiers.
Pitfalls
- Doing everything in one huge transaction: Long database transactions can lock tables and hurt performance. Try to commit in smaller chunks.
- Ignoring idempotency: If a job can be retried, make sure it is safe to run it more than once.
- No cleanup: Temporary files and job records can accumulate and fill disk. Add cleanup tasks.
- Blocking the event loop in async servers: CPU-heavy work in async endpoints will block other requests. Move it to workers or run in separate processes.
What You Should Take Away
- A long‑running task is any work that may outlive a normal HTTP request.
- Handling them inside a request is unsafe and harms performance.
- Use job queues and worker processes to run them in the background.
- Design APIs that accept the work, return a job id, and expose status endpoints.
- Think about timeouts, retries, idempotency, and progress tracking from the start.
You will use these patterns across many backend projects, from simple report generation to complex data pipelines.
Views: 8
KAHIBARO