32.8. Background Workers
Table of Contents
Why Background Workers Matter in a Production Backend
Most real applications perform tasks that are too slow or too unreliable to run inside the normal HTTP request. Examples:
- Sending emails after user actions
- Generating PDFs or reports
- Processing images or videos
- Syncing data with external APIs
- Rebuilding search indexes or caches
If you try to do all of this inside your API handler, your responses will be slow, users will retry, and your servers will be fragile.
Background workers solve this by moving work out of the request, into separate processes that can run independently and be scaled separately.
In this chapter you will design how background workers fit into your final project, and what you need to implement to run them reliably in production.
Key idea: Any task that is slow, unreliable, or not critical for the immediate user response should be moved to a background worker.
Role of Background Workers in Your Final Project
For the final production backend, you should explicitly decide:
- Which features use background tasks
- Which parts stay inside the main API process
- How the API and workers communicate
- How workers are deployed, monitored, and scaled
Common use cases that you can integrate into your project:
| Use case | Why background worker? |
|---|---|
| Sending registration emails | External SMTP latency, possible timeouts |
| Password reset emails | Same as above, plus rate limiting per user / IP |
| Generating invoices | Can take time, often CPU heavy (PDF generation) |
| Image processing | CPU heavy, can be batched |
| Cleaning old sessions | Periodic maintenance, no user waiting |
| Syncing with payment APIs | External API latency, retries needed |
| Cache warmup & rebuild | Expensive DB or search queries, can run in the background |
When you design your final project, pick at least 2 or 3 of these to implement with workers, so you exercise:
- Fire‑and‑forget jobs (e.g. send email)
- Retriable jobs (e.g. sync with payment provider)
- Scheduled jobs (e.g. nightly reports, periodic cleanup)
Architecture: How Workers Integrate with Your System
A typical production setup for background workers in your project:
- FastAPI application
Handles HTTP requests and does only the minimal work needed to respond quickly. When it needs a background task, it publishes a job into a broker (typically Redis). - Message broker (Redis)
Stores queued jobs. The API writes jobs, workers read jobs. It also stores job states, retries, and schedules (depending on tool). - Worker processes
Separate processes, often separate containers, that: - Subscribes to certain queues
- Processes jobs
- Handles retries, backoff, and failure logging
- Scheduler (optional but recommended)
To run periodic jobs, such as hourly or daily maintenance tasks.
A simple conceptual diagram:
- User → HTTP request → FastAPI → enqueue job in Redis → respond 200
- Worker listens to Redis → pulls job → executes task (e.g. send email)
In your final deployment, you will likely run:
- One or more API containers
- One or more worker containers
- One Redis container or managed Redis service
- Optional: a scheduler container
Choosing a Background Task Tool
You already saw background jobs and queues in earlier chapters. For the final project, choose a tool and stick with it.
Most common Python choices:
| Tool | Broker | Pros | Cons |
|---|---|---|---|
| Celery | Redis/RabbitMQ | Mature, powerful, widely used | Heavier, more configuration |
| RQ | Redis | Simple, easy to understand | Fewer advanced features |
| Dramatiq | Redis | Clean API, good performance | Smaller ecosystem than Celery |
FastAPI BackgroundTasks | None (same process) | Very simple, no broker needed | Not for heavy or long running tasks |
For a production‑style final project, prefer a real queue + worker such as:
- Celery + Redis, or
- RQ + Redis
Using BackgroundTasks from FastAPI is better suited for very small or demo projects because it runs in the same process as the API and does not survive restarts.
Whatever you choose, your architecture will look like:
- API adds job → Broker → Worker handles job
Designing Your Background Job APIs
You must decide how the rest of your application asks for background work. The main rule:
Your application code should call clean, simple functions like send_welcome_email(user_id)
These functions should not know if a task runs synchronously or in the background.
A typical pattern:
- Define a "task" function in a
tasksorjobsmodule. - Expose two interfaces:
- A Python function to call directly in unit tests or simple environments.
- A queued version to use in production.
Example design idea (Celery style, simplified code):
# app/tasks/email.py
from .celery_app import celery_app
@celery_app.task(name="send_welcome_email")
def send_welcome_email_task(user_id: int) -> None:
# Real email sending logic here
...
def send_welcome_email(user_id: int, use_background: bool = True) -> None:
if use_background:
send_welcome_email_task.delay(user_id=user_id)
else:
send_welcome_email_task(user_id=user_id)Then, in your FastAPI endpoint:
from app.tasks.email import send_welcome_email
@app.post("/register")
def register_user(...):
user = user_service.register(...)
send_welcome_email(user.id, use_background=True)
return {"id": user.id}
During unit tests, you might call send_welcome_email(user.id, use_background=False) so the email logic runs immediately and is easy to assert.
Identifying Tasks to Move to Workers in Your Project
Go through your final project features and list:
- Which operations can be slow or unpredictable
- Which do not need to finish before the user sees a response
- Which can be batched or scheduled
Example for an e‑commerce style project:
| Feature | Should be backgrounded? | Reason |
|---|---|---|
| Send order confirmation | Yes | External email service, user does not need to wait |
| Charge payment | No (usually) | User must know immediately if the payment succeeded |
| Generate invoice PDF | Yes | Can be slow, user can download later |
| Update analytics/event log | Yes | Non‑critical, can be processed asynchronously |
| Update product search index | Yes | Often slow updates, can lag slightly behind reality |
Then, for each "Yes", design:
- A dedicated task function
- A queue name (for priority or separation)
- Retry strategy
- Logging and metrics
Queue Design and Priorities
You can have a single queue, or multiple queues with different priorities. For the final project, multiple queues are a good exercise.
Example design:
| Queue name | What goes here | Priority |
|---|---|---|
emails | All email sending tasks | Medium |
critical | Payment callbacks, security related tasks | Highest |
maintenance | Cleanup tasks, slow reports | Lowest |
Practical guidance:
- Start with one queue if you are unsure.
- Once things work, split into critical and non‑critical queues.
- Run more worker processes for critical queues if needed.
With Celery, you can run workers that listen to specific queues:
celery -A app.celery_app worker -Q critical,emails -n worker_critical@%h
celery -A app.celery_app worker -Q maintenance -n worker_maintenance@%hError Handling, Retries, and Idempotency
In production, some jobs will fail:
- SMTP server returns 5xx
- External API is down
- Temporary network issues
Your worker system must handle this automatically.
Retries and Backoff
Most queue systems provide retry configuration. For example:
- Maximum retries: 5
- Delay between retries: exponential backoff, such as 10s, 30s, 60s, 5min, 15min
Example concept with Celery (conceptual code):
@celery_app.task(
name="send_welcome_email",
max_retries=5,
default_retry_delay=30 # seconds
)
def send_welcome_email_task(user_id: int):
try:
send_email(...)
except SomeTemporaryError as exc:
raise send_welcome_email_task.retry(exc=exc)
Always design background tasks to be idempotent as much as possible.
If a task runs twice, it should not corrupt data.
Idempotency examples:
- Sending the same welcome email twice is usually acceptable.
- Charging a payment twice is not acceptable. For this, you must:
- Use idempotency keys with the payment provider.
- Check your own database state before performing the action.
In your final project, explicitly decide:
- Which tasks are idempotent by design.
- Which tasks must check state before performing any external or database action.
Handling Permanent Failures
What happens after all retries fail?
At least:
- The job should end in a "failed" state, not be lost.
- You should have logs with enough context to debug.
- Optionally, store failed job info in your database.
Ideas for your project:
- A
failed_jobsdatabase table for very important tasks (e.g. payment webhooks). - An admin API endpoint to inspect failed jobs or to retry them manually.
- At minimum, structured logs that include:
- Job name
- Job ID
- Parameters (safe subset, no secrets)
- Exception message and stack trace
Monitoring and Observability for Workers
You cannot treat workers as "fire and forget" in production. You need to know:
- Are workers running?
- Is the queue growing too large?
- Are many jobs failing?
- How long do jobs take?
For your final project, plan:
- Health checks
At least one endpoint or command that tells you if the worker system is healthy. This can be: - A specific route in your application that tries a sample Redis operation.
- A separate process that checks worker status.
- Metrics
At minimum: - Number of jobs processed
- Number of failed jobs
- Average processing time per job type
- Queue length
If you integrate Prometheus, you can expose metrics like:
background_jobs_total{task="send_email"}background_jobs_failed_total{task="send_email"}background_jobs_duration_seconds_bucket{task="send_email", ...}
- Logging
Log at least: - Start of job with job type and ID
- Success with duration
- Failures with stack traces
This is essential when something "mysteriously" stops happening, like "users stopped receiving emails."
Deployment of Workers in Your Production Setup
In your final project, Docker and Compose (or Kubernetes) will orchestrate services. For workers, you typically:
- Use the same image as the API container.
- Change the entrypoint or command to run the worker instead of the API server.
Example docker-compose.yml snippet idea:
services:
api:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
depends_on:
- redis
worker:
build: .
command: celery -A app.celery_app worker -l info
depends_on:
- redis
scheduler:
build: .
command: celery -A app.celery_app beat -l info
depends_on:
- redis
redis:
image: redis:7-alpineKey points to plan for your project:
- The worker process must have the same configuration as the API:
- Environment variables (database URL, email config, 3rd party API keys)
- Same code version
- You can scale the worker independently:
- For Compose:
docker-compose up --scale worker=3 - On Kubernetes, you would use a separate Deployment for workers.
Make sure your CI/CD pipeline:
- Builds a single image
- Uses it for both API and worker deployments
- Migrates the database before scaling workers that might depend on schema changes
Scheduled and Periodic Jobs
Many background tasks are not triggered by a user action, but by time:
- Daily database cleanup
- Recalculate statistics
- Close unpaid orders after 24 hours
- Send daily digest emails
You can implement scheduling in several ways:
| Approach | Pros | Cons |
|---|---|---|
| External cron + API calls | Very simple, language agnostic | Requires a server cron or external service |
| Celery beat or worker scheduler | Integrated with the queue system | More configuration |
| Hosted schedulers (e.g. GitHub Actions cron, cloud schedulers) | Managed service | Another dependency |
Example conceptual Celery beat schedule (not full code):
from celery.schedules import crontab
celery_app.conf.beat_schedule = {
"cleanup-expired-sessions": {
"task": "cleanup_expired_sessions",
"schedule": crontab(minute=0, hour="*/1"), # every hour
},
"send-daily-report": {
"task": "send_daily_report",
"schedule": crontab(minute=0, hour=0), # every day at midnight
},
}For your final project, pick at least one scheduled job, for example:
- Cleanup expired refresh tokens every hour.
- Mark stale orders as "expired" daily.
- Send a daily summary to admin.
This will force you to handle:
- A task that is not triggered by an HTTP request.
- Code that must run correctly even if there are no users active.
Data Access and Transactions in Background Tasks
Background workers still need to access your database, cache, and configuration, just like the API does.
Key rules:
- Reuse the same database access layer
Do not write separate raw SQL for workers. Use the same ORM models and repository classes. - Handle transactions correctly
A task should: - Open a database session
- Perform its updates
- Commit or roll back on error
- Close the session
- Be careful with long‑running transactions
Do not hold a DB transaction open while doing slow external calls. Pattern: - Read necessary data from DB, then close the session.
- Perform external call.
- Open a new session to update final status.
Example pattern in pseudocode:
def process_invoice_task(invoice_id: int):
# 1. Load data
with db_session() as session:
invoice = repo.get_invoice(session, invoice_id)
data = build_invoice_data(invoice)
# 2. Slow external work (no DB transaction open)
pdf_bytes = generate_pdf(data)
# 3. Save result
with db_session() as session:
repo.save_invoice_pdf(session, invoice_id, pdf_bytes)
session.commit()For your final project, review each background task and ensure you do not keep transactions open longer than necessary.
Configuration and Secrets for Workers
Workers need access to the same sensitive configuration as your API:
- Database URL
- Redis URL
- Email SMTP credentials
- Third‑party API keys
Rules:
- Load configuration from environment variables in both API and worker.
- Do not hardcode secrets in code or Dockerfiles.
- In Docker, use environment sections or
.envfiles that are not committed to Git. - In CI/CD, use the platform's secrets manager to inject environment variables.
Example variables that both API and worker should read:
| Variable name | Used by |
|---|---|
DATABASE_URL | API, workers |
REDIS_URL | API, workers |
SMTP_HOST | Workers (email tasks) |
SMTP_USER | Workers |
SMTP_PASSWORD | Workers |
APP_ENV | API, workers (dev/prod) |
It is very common to forget that workers need the same configuration as the main app. Plan for this early in your project structure.
Testing Background Workers
For a production‑ready project, only testing HTTP endpoints is not enough. You should also test:
- The task functions themselves
- How your API enqueues jobs
- How tasks behave with failure and retries (at least at a unit level)
Practical testing strategies:
- Unit test the task logic synchronously
Call the underlying function directly, not through the broker:
def test_send_welcome_email_sends_email(mocker):
mock_send = mocker.patch("app.services.email.send_email")
send_welcome_email(user_id=1, use_background=False)
mock_send.assert_called_once()- Test that API endpoints add jobs
For example, patch the.delaymethod (for Celery) or enqueue method for your chosen library. - Integration testing with a real broker (optional but ideal)
- Start Redis in your test environment.
- Start a worker process.
- Enqueue a job from the test.
- Wait and then assert that the expected side effect occurred.
Even if full integration tests are complex, unit tests for the task functions are very important.
Putting It All Together for Your Final Project
When you design and implement the final production backend, summarize your background worker plan in a short internal document or README section:
Example checklist:
- Tooling
- [ ] Background processing library chosen (e.g. Celery + Redis)
- [ ] Common task base module created
- Use cases
- [ ] Registration email implemented as background job
- [ ] Password reset email implemented as background job
- [ ] At least one longer running task (e.g. invoice generation) in background
- Queues
- [ ] At least one queue
- [ ] Optional: separate queues for critical and non‑critical tasks
- Retries and idempotency
- [ ] Retry policy configured per task
- [ ] All tasks safe to rerun or protected by idempotency logic
- Scheduling
- [ ] At least one scheduled job (e.g. cleanup)
- Deployment
- [ ] Worker container defined in Docker Compose or Kubernetes
- [ ] Same image and configuration as API
- [ ] Easy to scale worker count
- Monitoring
- [ ] Logs for job start, success, failure
- [ ] Basic metrics or at least queue length visibility
- [ ] Health checks or manual commands to verify workers are alive
By following this plan, your final project will not just have background tasks as a side feature. It will have a real, production‑style background processing system, with proper architecture, configuration, deployment, and monitoring.
Views: 8
KAHIBARO