Backend Development
Hands-onIntermediate 40 min· Production-ready APIs · 12 of 20

Background jobs and queues

Keep your API fast by moving slow work like SMS, email and PDFs into background jobs. Use FastAPI BackgroundTasks for small things, then build a Redis queue with a separate arq worker, retries and job status.

When a customer places an order, your API must save it, and also send an SMS receipt, notify the kitchen and maybe email an invoice. If the request waits for all of that, the customer stares at a spinner for several seconds, and if the SMS provider is down the whole order fails. The fix is to do only the essential work during the request and hand the rest to a background job.

What you'll learn and build: first FastAPI's built-in BackgroundTasks for small jobs, then a proper job queue with a separate worker process using Redis and the arq library: enqueue a receipt job from the API, run it in a worker, retry it when it fails, and check its status.

Prerequisites: Python 3.10+, basic FastAPI, Redis running locally (see *Caching with Redis* for the Docker command) and curl.

Why background jobs?

What belongs in the background

WorkNeeds to finish before replying?Do it...
Save the order in the databaseYes, or the order is lostIn the request
Send SMS or email receiptNoIn the background
Generate a PDF invoice or a sales reportNo, and it may take longIn the background
Resize uploaded imagesNoIn the background
Nightly clean-up or daily summaryNot tied to any requestScheduled job

Background jobs give you three things: speed (the response is fast), reliability (a failed job can be retried without failing the customer's request) and smoothing (a sudden burst of orders becomes a queue that workers drain at their own pace).

Level 1: FastAPI BackgroundTasks

For small, quick, non-critical work, FastAPI can run a function after it has sent the response, in the same process. Save this as main.py, run uvicorn main:app --reload and call curl -X POST http://127.0.0.1:8000/orders.

main.py
import time

from fastapi import BackgroundTasks, FastAPI

app = FastAPI()


def send_receipt(order_id: int, phone: str) -> None:
    time.sleep(3)  # pretend to call an SMS provider
    print(f"Receipt for order {order_id} sent to {phone}")


@app.post("/orders", status_code=201)
def create_order(background_tasks: BackgroundTasks):
    order_id = 1042  # pretend we saved the order
    background_tasks.add_task(send_receipt, order_id, "9876543210")
    return {"order_id": order_id, "status": "received"}

The response returns immediately; three seconds later the receipt line prints in the server terminal.

This is easy, but it has real limits: - The job lives inside the web server process. If the server restarts or crashes, the job is lost. - There is no retry and no record of whether it worked. - Heavy jobs steal CPU and memory from the API that serves customers.

Use it for things like writing an audit log line. For anything you cannot afford to lose, use a queue.

Level 2: a queue and a worker

A job queue has three parts:

1. Producer: your API. It puts a message ("send receipt for order 1042 to this phone") on the queue. 2. Broker: the queue's storage. Here, Redis. Messages wait there safely, even if everything else restarts. 3. Worker: a separate process that takes messages and runs the function.

It is like the order slips in the kitchen. The counter (API) clips a slip on the rail (Redis) and carries on serving. The barista (worker) takes slips one by one. If the barista is busy, the slips wait. You can add more baristas without touching the counter.

Popular Python tools are Celery (the most widely used, many features), RQ and arq. In Node, BullMQ is the usual choice. We use arq here because it is small, works with async code and runs on Windows, macOS and Linux. The ideas carry over to all of them.

Setup

  1. 1Make sure Redis is running (docker run --name redis -p 6379:6379 -d redis, or start the container you made earlier).
  2. 2Create a folder jobs-demo, make and activate a virtual environment.
  3. 3Install: pip install arq fastapi uvicorn.
  4. 4Create worker.py and main.py from the code below.
  5. 5Start the worker in one terminal and the API in another.
Terminal
pip install arq fastapi uvicorn
worker.py
import asyncio
import random

from arq import Retry
from arq.connections import RedisSettings


async def send_receipt(ctx, order_id: int, phone: str) -> str:
    """Runs inside the worker process, not inside the API."""
    attempt = ctx["job_try"]  # 1 on the first try
    print(f"[try {attempt}] sending receipt for order {order_id} to {phone}")
    await asyncio.sleep(2)  # pretend to call an SMS provider
    if random.random() < 0.5:  # pretend the provider is flaky
        print("  provider timed out, will retry")
        raise Retry(defer=attempt * 5)  # wait 5s, then 10s, ...
    print(f"  receipt for order {order_id} sent")
    return "sent"


class WorkerSettings:
    functions = [send_receipt]
    redis_settings = RedisSettings()  # localhost:6379
    max_tries = 3

The job function takes ctx first (arq passes it), then your arguments. raise Retry(defer=...) asks arq to run the job again later.

main.py
from contextlib import asynccontextmanager

from arq import create_pool
from arq.connections import RedisSettings
from arq.jobs import Job
from fastapi import FastAPI, HTTPException


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.queue = await create_pool(RedisSettings())
    yield
    await app.state.queue.aclose()


app = FastAPI(lifespan=lifespan)


@app.post("/orders", status_code=201)
async def create_order():
    order_id = 1042  # pretend we saved the order in the database
    job = await app.state.queue.enqueue_job("send_receipt", order_id, "9876543210")
    return {"order_id": order_id, "status": "received", "receipt_job": job.job_id}


@app.get("/jobs/{job_id}")
async def job_status(job_id: str):
    job = Job(job_id, app.state.queue)
    status = await job.status()
    if status.value == "not_found":
        raise HTTPException(status_code=404, detail="No such job")
    return {"job_id": job_id, "status": status.value}

create_pool connects to Redis once at startup. enqueue_job takes the function's name as a string, then its arguments, and returns immediately.

Terminal 1 (worker)
python -m arq worker.WorkerSettings

Run from the folder that contains worker.py. Leave it running.

Terminal 2 (API)
python -m uvicorn main:app --port 8000

Use the same virtual environment.

Watch it work

Terminal 3
curl -X POST http://127.0.0.1:8000/orders
curl http://127.0.0.1:8000/jobs/PASTE_JOB_ID_HERE

The first call returns at once with a receipt_job id. Use it in the second call, several times, over the next few seconds.

API responses over time
{"order_id":1042,"status":"received","receipt_job":"523cc532e44a46e8a0a6c51668a34146"}

{"job_id":"523c...","status":"in_progress"}
{"job_id":"523c...","status":"deferred"}
{"job_id":"523c...","status":"complete"}
Worker terminal (the failures are random, so yours will differ)
[try 1] sending receipt for order 1042 to 9876543210
  provider timed out, will retry
[try 2] sending receipt for order 1042 to 9876543210
  receipt for order 1042 sent

The order request came back instantly, while the worker did the slow, flaky part in the background. When the pretend SMS provider timed out, the job was retried later with a growing delay instead of failing the customer's order. Status deferred means "waiting for its retry time"; queued means waiting for a free worker.

Stop the worker with Ctrl+C while a job is deferred, start it again, and the job still runs: the message lives in Redis, not in the worker.

Making jobs safe

Queues usually promise at-least-once delivery: a job may run more than once (a worker crashed halfway, a retry after a timeout that actually succeeded). So write jobs to be idempotent: running twice has the same effect as running once.

Good job habits

PracticeWhy
Pass IDs, not whole objects: send_receipt(order_id)The data may change; load fresh data inside the job
Make the job idempotentRetries must not text the customer twice. Record receipt_sent_at on the order and skip if set
Set limits: max tries, timeouts, backoffAvoids endless retries hammering a broken service
Keep failures visibleLog failures and alert; look at jobs that failed after the last retry
Keep secrets and big payloads out of messagesMessages sit in Redis; store large files elsewhere and pass a reference

Retries are not a fix

A queue does not make a broken job work. A job that always fails will fail on every retry. Cap the number of tries, and log each failure with the job id and the reason so a person can look at it.

Run workers separately

In production, run the worker as its own service (its own Docker container or process) so you can scale and restart it separately from the API. See *Containerise your backend with Docker*.

Scheduled jobs

Some jobs are not triggered by a request but by the clock: "email the owner a daily sales summary at 9 pm". On a single Linux server this is cron. With arq you can add cron_jobs to WorkerSettings; Celery has "beat"; cloud platforms have schedulers. Keep scheduled jobs idempotent too, because a scheduler can fire twice after a restart.

How this comes up in interviews

Typical questions: "How would you send emails without slowing the API?", "What happens if a worker crashes mid-job?", "What does idempotent mean and why does it matter for queues?", "Celery vs a simple thread or BackgroundTasks?" and "How do you handle a job that keeps failing?" (retry with backoff, max tries, then a dead-letter or failed list for manual review). Mention at-least-once delivery.

Prompt · Which work belongs in the background?
I am building [describe your app]. When a user does [action, e.g. places an order or uploads a video], these things happen: [list steps]. Split them into work that must finish before the HTTP response and work that should become a background job. For each background job, say what to pass in the message, how to make it idempotent, and what should happen after three failures.

What to expect: A good answer keeps the essential database write in the request, passes IDs not objects, and describes an idempotency check and a failure path.

Try it: Make the receipt idempotent

Add an in-memory set already_sent = set() at the top of worker.py. At the start of send_receipt, return "already sent" if the order id is in the set, and add the id to the set only after a successful send. Why add it *after*, not before?

Try it: Add a second job

Create a job notify_kitchen(ctx, order_id) that waits one second and prints Kitchen notified for order <id>. Register it in WorkerSettings.functions and enqueue it from create_order right after the receipt job. Start two workers (two terminals) and enqueue several orders. What do you notice?

Key takeaways

  1. Do the essential work in the request and hand slow or unreliable work (SMS, email, PDFs, images) to background jobs.
  2. FastAPI BackgroundTasks is simple but in-process: no retries, and jobs are lost if the server dies.
  3. A queue has a producer (API), a broker (Redis) and workers that run separately and can be scaled.
  4. Delivery is at-least-once, so make jobs idempotent and pass IDs rather than whole objects.
  5. Set retry limits and backoff, log failures, and run workers as their own service.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why is FastAPI BackgroundTasks unsuitable for sending important payment confirmations?

2. In a job queue setup, what is the role of Redis?

3. What does it mean for a job to be idempotent?

4. Why should a job receive order_id rather than the full order object?

5. A job calls an SMS service that is down for an hour. What is a sensible policy?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free