Lesson 2 - Background Tasks

Welcome to Background Tasks

Some of the work an endpoint triggers doesn’t need to finish before the client hears back. When someone creates a task, you might want to send a welcome email, write an audit record, or clean up a temporary file. None of that changes the answer you give the caller — yet if you do it inline, the caller waits for all of it before getting their response. FastAPI’s BackgroundTasks lets you flip that around: return immediately, then run the slower side-effects after the response has been sent.

This lesson builds directly on the event loop you met in Lesson 1, and it’s deliberately practical — every idea is grounded in a small endpoint you can run.

By the end of this lesson, you will be able to:

  • Explain why slow side-effects shouldn’t block the caller’s response
  • Use BackgroundTasks and add_task to run work after the response is sent
  • Add multiple ordered tasks, using sync or async functions
  • Recognize the limits of background tasks and when a real task queue is the right tool

Let’s start with the problem they solve.


The Problem: Don’t Make the Caller Wait for Side-Effects

Picture a “create task” endpoint. The real job — saving the task — is fast. But you also want to email the task’s owner. Sending email means talking to an SMTP server or a third-party API, which can take a second or two (sometimes more). If you do it inside the request, the timeline looks like this:

client → save task (fast) → send email (slow...) → respond

The caller sits and waits for the email to go out before they see 201 Created, even though the email has nothing to do with the answer. Worse, if the mail server is having a bad day, your endpoint is slow for everyone who creates a task. The same trap applies to writing an audit log to a slow store, cleaning up temporary files, or pinging a webhook.

The fix is to recognize that these are side-effects: useful work that doesn’t shape the response. The caller only needs to know the task was accepted. Everything else can happen after you’ve replied. That’s exactly what background tasks give you — and because the response goes out first, the perceived latency drops to just the fast part.


BackgroundTasks: Run Work After the Response

To schedule post-response work, declare a parameter typed as BackgroundTasks. FastAPI sees the type hint and injects an object you can attach work to. You register a function with add_task(func, *args), then return your response as usual. FastAPI sends the response first, then runs the registered tasks.

from fastapi import FastAPI, BackgroundTasks

app = FastAPI()
LOG = []

def write_log(message: str):
    LOG.append(message)

@app.post("/notify")
def notify(email: str, background_tasks: BackgroundTasks):
    background_tasks.add_task(write_log, f"sent welcome email to {email}")
    return {"status": "accepted", "email": email}

Notice what add_task takes: the function itself (no parentheses — you’re handing over the function, not calling it), followed by the arguments FastAPI should pass when it runs the function later. Here we pass the message string. The endpoint returns right away with "accepted".

Let’s prove the task actually runs, and that it runs after the response is built. We send a request, check the JSON we got back, then inspect the module-level LOG:

from fastapi.testclient import TestClient

client = TestClient(app)
response = client.post("/[email protected]")
print(response.status_code)
print(response.json())
print(LOG)
200
{'status': 'accepted', 'email': '[email protected]'}
['sent welcome email to [email protected]']

The response came back as {'status': 'accepted', ...} — and the LOG now contains our message, which the endpoint never appended itself. The only place write_log runs is the background task, so its presence is direct proof the task fired after the response was produced.

One detail about testing: with TestClient, the background task is guaranteed to have completed by the time the request context exits, so you can assert on LOG immediately after the call. That’s why this example is so easy to verify.


Multiple Tasks, In Order, Sync or Async

You’re not limited to one task. Call add_task as many times as you like, and FastAPI runs them in the order you added them. The functions can be plain def or async def — FastAPI handles both, the same way it does for endpoints (Lesson 1).

Here a “create task” endpoint records an audit entry first, then queues an email. One handler is synchronous, the other is a coroutine:

import asyncio
from fastapi import FastAPI, BackgroundTasks

app = FastAPI()
LOG = []

def write_audit(action: str):
    LOG.append(f"audit: {action}")

async def send_email(to: str):
    await asyncio.sleep(0)          # stands in for real async I/O
    LOG.append(f"email queued for {to}")

@app.post("/tasks")
def create_task(title: str, background_tasks: BackgroundTasks):
    background_tasks.add_task(write_audit, f"created task {title!r}")
    background_tasks.add_task(send_email, "[email protected]")
    return {"status": "created", "title": title}

We add the audit task before the email task. Running it:

from fastapi.testclient import TestClient

client = TestClient(app)
response = client.post("/tasks?title=Ship+release")
print(response.status_code)
print(response.json())
print(LOG)
200
{'status': 'created', 'title': 'Ship release'}
["audit: created task 'Ship release'", 'email queued for [email protected]']

The LOG shows both entries, and they appear in the order we registered them — audit first, email second — even though one task is sync and the other is async. That ordering is a guarantee you can rely on: if your cleanup must happen before your notification, just add it first.

Background tasks share your app’s process

BackgroundTasks runs in the same process as your app, after the response is sent. That makes it perfect for light post-response work — a quick log write, a cleanup, firing off a notification. But it inherits your process’s lifetime and resources: if the server restarts or crashes before the task finishes, the work is simply lost, and a heavy task still competes for the same CPU and memory as live requests. For anything long-running, retried, or that must not be lost, use a real task queue like Celery, RQ, or Dramatiq, which run on separate workers with persistence and retries.


The Limits: When to Reach for a Task Queue

Background tasks are intentionally simple, and that simplicity is also their boundary. Because they live inside your app process and start only after the response:

  • No persistence. If the process restarts, deploys, or crashes before a task runs, the task vanishes. There’s no record it was ever scheduled.
  • No retries. If the email send raises, the task just fails. Nothing tries again, and the caller already got a 200 — so they have no idea anything went wrong.
  • No isolation. A slow or CPU-heavy task runs in the same process as your live requests, competing for the same resources. A genuinely long job can degrade the whole server.
  • No visibility. There’s no built-in way to inspect, schedule, or monitor a background task once it’s queued.

For light, best-effort, “nice to have” work — logging, audit trails, cache warming, deleting a temp file — none of that matters, and BackgroundTasks is exactly right. But the moment the work is long-running, must not be lost, needs retries, or needs to be observed, you’ve outgrown it. That’s the job of a dedicated task queue: tools like Celery, RQ, and Dramatiq run your jobs on separate worker processes, persist them in a broker (often Redis or RabbitMQ), retry on failure, and give you visibility into what’s running. The skill is matching the tool to the stakes: light post-response side-effects get BackgroundTasks; important, durable jobs get a queue.


Practice Exercises

Exercise 1: Background task or inline?

For a “place order” endpoint, decide which of these should run inline (before the response) and which fit a background task: (a) charging the customer’s card, (b) writing an “order placed” line to an audit log, (c) sending an order-confirmation email.

Hint

(a) Charging the card is part of the answer — if it fails, the order didn’t succeed, so it must run inline and shape the response. (b) and (c) are side-effects that don’t change whether the order was placed, so they fit background tasks. Note that (c), the confirmation email, is best-effort here; if it absolutely must not be lost, that’s a signal to move it to a real task queue.

Exercise 2: Two tasks, the right order

You want to delete an uploaded temporary file and then log “cleanup complete.” Write the two add_task calls so the log entry is guaranteed to come after the deletion.

Hint

FastAPI runs background tasks in the order you add them, so add the deletion first and the log second: background_tasks.add_task(remove_temp_file, path) then background_tasks.add_task(write_log, "cleanup complete"). Because ordering is guaranteed, the log task will run only after the deletion task has finished.

Exercise 3: Outgrowing background tasks

A teammate uses a background task to generate a 200-page PDF report and email it, and notices that under load the server slows down and some reports never arrive after a deploy. What’s going wrong, and what should they use instead?

Hint

The PDF generation is heavy and long-running, so it competes for the same process resources as live requests (slowing the server), and because background tasks have no persistence, any task still running during a deploy/restart is lost (missing reports). This work has outgrown BackgroundTasks: move it to a real task queue like Celery, RQ, or Dramatiq, which runs jobs on separate workers with persistence and retries.


Summary

Some work an endpoint triggers — sending email, writing audit logs, cleanup — is a side-effect that doesn’t shape the response, so the caller shouldn’t have to wait for it. FastAPI’s BackgroundTasks lets you return immediately and run that work after the response is sent: declare a background_tasks: BackgroundTasks parameter, register work with add_task(func, *args), and return as usual. You can add multiple tasks (they run in order), and they can be sync or async functions. The catch is that they run in your app’s process, so they’re ideal for light, best-effort work but offer no persistence, retries, isolation, or visibility — which is exactly when you reach for a real task queue like Celery, RQ, or Dramatiq.

Key Concepts

  • Side-effect — work an endpoint triggers that doesn’t change the response (email, logging, cleanup).
  • BackgroundTasks — an injected object you attach post-response work to via a typed parameter.
  • add_task(func, *args) — register a function (and its arguments) to run after the response is sent.
  • Ordering — multiple tasks run in the order you add them; functions may be sync or async.
  • The limits — same-process execution means no persistence, retries, or isolation; use a task queue for durable, heavy, or important jobs.

Why This Matters

Returning fast is one of the most visible parts of a good API — callers feel a 200 that arrives instantly versus one that hangs while an email goes out. Background tasks let you deliver that snappy experience for the common case of light side-effects, with almost no extra code. Just as important is knowing their ceiling: recognizing when a job is too heavy or too important for in-process tasks, and reaching for Celery, RQ, or Dramatiq instead, is what keeps a growing API both fast and reliable.


Next Steps

Continue to Lesson 3 - Streaming and Server-Sent Events

Send a response incrementally — stream large files and push live updates to the client with StreamingResponse and SSE.

Back to Module Overview

Return to the Async, Background Work, and Streaming module overview


Continue Building Your Skills

You can now return a response instantly and let slower side-effects finish in the background — and you know the line where in-process tasks stop being enough. Next you’ll go the other direction: instead of finishing fast, you’ll keep a response open and send data incrementally, streaming large files and pushing live updates to the client.