17.2. Background Jobs
Table of Contents
Why Background Jobs Exist
When you build a backend, many tasks do not need to finish while the user is waiting for a response. These are perfect candidates for background jobs.
Examples of such tasks:
- Sending emails, like welcome emails or password reset emails
- Generating PDF reports
- Resizing or processing uploaded images
- Importing or exporting large CSV files
- Clearing old data from the database
- Synchronizing data with external services
If you do these inside the main HTTP request, the user waits. If the email provider is slow or a report takes 20 seconds to generate, the user sees a spinning loader or a timeout. With background jobs, the API can:
- Accept the request.
- Save what is needed to process the task later.
- Respond quickly.
- Process the heavy work in the background.
Key rule: Any work that is slow, not user-facing, or can be retried later is usually a good candidate for a background job.
Synchronous Work vs Background Jobs
To understand background jobs, contrast them with synchronous work.
Synchronous work in an HTTP handler
Synchronous means your application waits until the task is done.
Example without background jobs:
@app.post("/register")
def register_user(user: UserIn):
# 1. Create user in database
db_user = create_user(user)
# 2. Send welcome email (might be slow)
send_welcome_email(db_user.email)
# 3. Return response
return {"id": db_user.id, "email": db_user.email}
If send_welcome_email takes 5 seconds, the user waits 5 seconds.
Problems:
- Slow user experience.
- If email sending fails, the whole registration may fail.
- If you need to send 1,000 emails, the API can be blocked for a long time.
Moving work to a background job
With a background job, you split the flow:
@app.post("/register")
def register_user(user: UserIn):
db_user = create_user(user)
# Enqueue background job
task_id = enqueue_send_welcome_email(db_user.id)
return {
"id": db_user.id,
"email": db_user.email,
"welcome_email_task_id": task_id,
}A separate worker process later runs:
def process_send_welcome_email(user_id: int):
user = get_user_by_id(user_id)
send_welcome_email(user.email)The HTTP request is now fast. Email processing is independent, can be retried, and can be monitored.
Types of Background Jobs
Not all background jobs are the same. It is useful to classify them, because each type has slightly different requirements.
1. Immediate jobs
These should run as soon as possible after the request, but not necessarily inside the request.
Examples:
- Send a confirmation email after signup.
- Push a notification when a new comment is created.
- Index a new product in a search engine.
Typical behavior:
- Scheduled now.
- Executed once as soon as a worker is available.
Pseudocode example:
# In your API
task_id = job_queue.enqueue("send_confirmation_email", user_id=user.id)
# In your worker
def send_confirmation_email(user_id):
# send the email here
pass2. Delayed jobs
These should run later, not immediately.
Examples:
- Send a reminder email 24 hours before an event.
- Delete inactive temporary accounts after 7 days.
- Charge a user after a free trial that ends in 14 days.
Typical behavior:
- Enqueued now, with a future execution time.
Pseudocode:
# run in 24 hours
job_queue.enqueue_in(
delay_seconds=24 * 3600,
task_name="send_event_reminder",
event_id=event.id,
)3. Periodic or scheduled jobs
These run repeatedly on a schedule.
Examples:
- Clean up old logs every night.
- Recalculate statistics every hour.
- Sync data from a third party every 5 minutes.
This is similar to a cron job. Some job systems integrate scheduling, but often you use a separate scheduler that enqueues jobs.
Example schedules:
| Schedule | Example task |
|---|---|
| Every minute | Poll an external service |
| Every 5 minutes | Rebuild search index chunk |
| Every hour | Generate aggregate metrics |
| Every day at 01:00 | Delete old temporary uploads |
4. Long-running jobs
These take a long time, for example several minutes or more.
Examples:
- Generating a big PDF report.
- Running heavy machine learning inference on many records.
- Migrating or transforming a lot of data.
These jobs often need:
- Progress tracking.
- The ability to resume or retry.
- Some limit on how many such jobs run at once to avoid overloading servers.
Common Use Cases with Examples
Here are concrete scenarios that are very common in backend systems.
Sending emails
Sending email is a classic background job.
Basic flow:
- API receives a request that needs an email.
- API enqueues a job like
send_email(user_id, template_name). - Worker pulls the job, loads the user, renders the email, sends it.
Example using pseudocode:
# API handler
def post_register(user):
new_user = create_user(user)
job_queue.enqueue("send_welcome_email", user_id=new_user.id)
return {"id": new_user.id}
# worker
def send_welcome_email(user_id):
user = get_user(user_id)
body = render_template("welcome_email.html", user=user)
email_client.send(to=user.email, subject="Welcome!", body=body)Image processing
Image-related operations are often slow and CPU intensive.
Tasks:
- Resize images to thumbnails.
- Compress high-resolution uploads.
- Generate different sizes for mobile and desktop.
Example:
# API: upload endpoint
def upload_image(file):
image_id = save_image_original(file)
job_queue.enqueue("generate_thumbnails", image_id=image_id)
return {"image_id": image_id}
# worker
def generate_thumbnails(image_id):
original = load_image(image_id)
for size in [64, 128, 256]:
thumbnail = resize_image(original, size)
save_thumbnail(image_id, size, thumbnail)Data imports and exports
Big data files, like CSV imports, should not be processed in a synchronous request.
Flow:
- User uploads a CSV file.
- API stores file and enqueues job
import_csv(file_path, user_id). - Worker reads the file, validates rows, inserts data into DB.
- Worker updates some
import_statustable with progress.
Example of tracking import:
| Column | Description |
|---|---|
id | Import job id |
user_id | Who requested the import |
status | pending, processing, completed, failed |
total_rows | Total rows detected |
processed | Rows already processed |
errors | Error message or count |
The frontend can poll /imports/{id} to show progress.
Cleanup and maintenance
Background jobs are great for maintenance tasks.
Examples:
- Remove records older than a certain date.
- Remove expired sessions.
- Delete temporary files that are no longer needed.
Scheduled job:
# runs every night
def cleanup_old_sessions():
delete_from_sessions_where_expires_before(now())Designing Background Jobs
Good background job design makes your system reliable and easier to debug.
Job payloads: what to put into a job
A job has a payload, the data needed to run it. Design payloads carefully.
Better to pass:
- Primary keys or IDs.
- Small bits of metadata.
Avoid passing:
- Large JSON contents.
- Whole images or files.
Example of a good payload:
{
"task": "send_order_confirmation",
"order_id": 12345
}The worker can load the order details from the database. If order details change later, the job uses the most up-to-date information.
Idempotency: safe to run multiple times
An idempotent job can run once, twice, or many times with the same result.
Important rule: Design background jobs to be idempotent so retries and duplicates do not cause data corruption or duplicate side effects.
Examples:
- Sending an email is not naturally idempotent, but you can check a flag.
def send_welcome_email(user_id):
user = get_user(user_id)
if user.welcome_email_sent:
return # already sent, do nothing
actually_send_welcome_email(user.email)
mark_welcome_email_sent(user_id)- Charging a credit card should be idempotent. Many payment providers offer idempotency keys. You send the same key for retries, and they guarantee only one charge.
Job naming and organization
Group jobs logically:
email.send_welcomeemail.send_password_resetreports.generate_user_reportcleanup.delete_old_sessions
This helps with:
- Filtering logs.
- Monitoring specific types of jobs.
- Routing jobs to specific worker pools.
You can also place jobs in modules or packages that reflect your domain.
Background Job Lifecycle
Almost every job follows a simple lifecycle:
- Enqueued
The application creates a job and puts it into a queue. - Picked up by a worker
A worker process retrieves a job from the queue. - Processing
The job handler runs your code. - Completed
If it succeeds, the job is marked completed and removed from the queue, or its result is stored. - Failed
If there is an error, the job may be retried or marked failed permanently.
You might also track extra states:
scheduledfor jobs set to run in the future.retryingor a retry count.canceledif you support cancellation.
Example state transitions:
| From | To | Reason |
|---|---|---|
queued | processing | Worker starts work |
processing | completed | Job finished without error |
processing | failed | Job raised an exception |
failed | queued | Retry scheduled |
queued | scheduled | If it is a job for a future time |
Error Handling and Retries
Background jobs will fail sometimes. Maybe:
- The external service is down.
- The database is locked.
- A network timeout happens.
You cannot avoid failures, but you can design how to respond.
Automatic retries
Common strategy:
- Try immediately.
- If it fails, wait a bit, then retry.
- After a maximum number of attempts, mark as permanently failed.
A simple retry policy:
| Attempt | Delay before next attempt |
|---|---|
| 1 | 0 seconds (first) |
| 2 | 10 seconds |
| 3 | 60 seconds |
| 4 | 300 seconds |
This is an example of exponential backoff.
Key rule: Always use retries with backoff for operations that can fail due to temporary issues like network problems.
Distinguish between permanent and temporary errors
Some errors are permanent, retried attempts will never succeed:
- Invalid email address format.
- User does not exist in the database.
- Malformed data in a CSV row that violates constraints.
Some errors are temporary:
- Timeout calling an email provider.
- External API rate limit.
- Database connection error.
Your job code can:
- Detect permanent errors and fail the job immediately.
- Detect temporary errors and let them trigger retries.
Example:
def send_email(user_id):
user = get_user(user_id)
if not is_valid_email(user.email):
# permanent failure
raise PermanentJobError("Invalid email")
try:
email_client.send(user.email, ...)
except NetworkError as e:
# temporary, should be retried
raise TemporaryJobError(str(e))The job system can use error type to decide retry behavior.
Dead-letter queues
A dead-letter queue (DLQ) holds jobs that failed permanently after all retries.
Why keep them:
- Manual inspection and debugging.
- Possibly manual reprocessing after data fixes.
Flow:
- Job fails many times.
- Job is moved to the DLQ with its last error.
- A developer or admin reviews DLQ periodically.
Monitoring and Observability for Jobs
Background jobs are invisible to users, so you must add ways to observe them.
Basic metrics
Useful metrics for job systems:
| Metric | Meaning |
|---|---|
| Queue length | How many jobs are waiting |
| Jobs processed per minute | Throughput |
| Average job duration by type | How long a job takes |
| Number of failures per type | Which job types are most problematic |
| Retry counts | How many retries are happening |
If the queue grows constantly, workers may be too few or jobs too slow.
Logs
You should log:
- Job start and completion.
- Important parameters, like job id and type.
- Errors and stack traces.
Example log structure:
{
"event": "job_started",
"job_id": "abc123",
"job_type": "send_welcome_email",
"timestamp": "2026-08-27T12:00:00Z"
}{
"event": "job_failed",
"job_id": "abc123",
"job_type": "send_welcome_email",
"error": "Network timeout",
"attempt": 2,
"timestamp": "2026-08-27T12:00:10Z"
}Dashboards
For serious systems, you want:
- A dashboard that shows queue lengths and processing rates.
- A UI to inspect individual jobs, especially failed ones.
- The ability to retry or cancel specific jobs.
Many job processing tools provide this out of the box, or you can build simple endpoints that query job status and show it.
Patterns and Best Practices
Here are patterns that help you design reliable background jobs.
1. Keep jobs small and focused
One job should do one clear thing. For example:
- Good:
send_invoice_email(invoice_id) - Bad:
create_invoice_and_send_email_and_update_balance_and_audit_log(...)
Small jobs:
- Are easier to test.
- Fail less often.
- Are easier to retry.
- Are easier to reason about.
If you need several steps, you can chain jobs:
- Job
generate_invoiceonce done, enqueues - Job
send_invoice_email, which then enqueues - Job
update_invoice_status.
2. Do not block jobs on user input
Jobs should run automatically. They should not ask for more input from the user. If a job needs parameters, the request that enqueues it must provide them.
3. Store any important results
If a job produces a result that you need later, store it in a database or file storage.
Examples:
- Report file path written in a
reportstable. - Summary statistics stored in a
metricstable.
A job should not just log the result; logs are not designed for easy querying.
4. Avoid heavy shared state
Jobs are often processed by multiple workers, possibly on different machines.
Avoid:
- Relying on in-memory global variables.
- Modifying shared files directly without locking.
Use:
- Databases.
- Object storage.
- Cache systems like Redis when appropriate.
5. Consider ordering requirements
Sometimes order matters. For example, you might need:
- Job B to run after job A.
You can handle this in multiple ways:
- Have job A enqueue job B after it succeeds.
- Use a workflow or orchestration system that supports dependencies.
- In job B, check that conditions created by job A are already satisfied.
Simple Background Job Implementation Idea
Even without a full job framework, you can build a very simple job system for learning purposes.
Example: Database-backed job queue
Table:
CREATE TABLE jobs (
id SERIAL PRIMARY KEY,
type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);Workflow:
- API inserts a job:
INSERT INTO jobs (type, payload) VALUES ('send_email', '{"user_id": 1}');- Worker loop (pseudocode):
while True:
job = fetch_next_queued_job()
if not job:
sleep(1)
continue
try:
mark_job_as_processing(job.id)
run_job(job.type, job.payload)
mark_job_as_completed(job.id)
except Exception as e:
increment_attempts_and_record_error(job.id, str(e))
if job.attempts >= MAX_ATTEMPTS:
mark_job_as_failed(job.id)
else:
mark_job_as_queued(job.id) # for retryThis is not as powerful as real tools, but it shows the core ideas:
- Enqueue by inserting into a table.
- Worker polls and processes jobs.
- States change from queued to processing to completed or failed.
- Retries happen by moving back to queued.
When Not to Use Background Jobs
Sometimes developers move everything to background jobs, which is not always necessary.
Avoid background jobs when:
- The task is very fast and simple, for example updating a small field.
- The user must see the result immediately, such as validating a password.
- You do not have a worker environment available yet, during very early prototypes.
A good heuristic:
Heuristic: If a task consistently takes more than about 200β500 milliseconds or depends on external services, evaluate if it should be a background job.
Summary
Background jobs allow your backend to:
- Keep HTTP responses fast.
- Handle slow or unreliable external services gracefully.
- Run scheduled and maintenance work.
- Process large or long-running tasks.
Key ideas to remember:
- Design job payloads with IDs, not large blobs of data.
- Make jobs idempotent to survive retries and duplicates.
- Track job state, retries, and errors.
- Monitor queues and workers with metrics and logs.
- Keep jobs focused on single responsibilities.
In the next chapters about message queues, workers, and specific tools like Celery and Redis, you will see how to implement these background job concepts in real applications.
Views: 16
KAHIBARO