Background Tasks

Work that should happen after the response has gone - and the point at which it needs a real queue instead.

Overview

The mechanism

A background task is work that runs after the response has already been sent. The client gets its answer quickly, and the slower job carries on afterward. Typical examples are sending a confirmation email, writing an audit log, or resizing an image. The user does not need to wait for any of them.

FastAPI includes a simple tool for this called BackgroundTasks. You ask for it in your handler, add a function to it, and the framework runs that function once the response goes out. It needs no extra services or setup, which makes it very convenient for small jobs.

This article starts with the mechanism and how little code it takes. It then explains ordering precisely, so you know exactly when the task starts relative to the response and to any cleanup in your dependencies. That timing is more subtle than it seems.

An honest section follows on what background tasks are not. They are not a job queue. They run inside the same process, they vanish if the server restarts, and they have no retry or tracking. The next section explains when to move to a real queue, and what signs tell you it is time.

The article ends with failure. If a background task raises an error, the client has already received a success response, so nobody will see the problem unless you log it. You will learn how to handle that sensibly.

Use this tool freely for light work, and know where its limits are.

It is a small feature with a clear purpose. Used well, it keeps your endpoints quick and your code simple. Used for the wrong job, it hides problems until production. Knowing the difference is the goal here, and a few minutes of reading will give you that. Each section is short and ends with a habit you can apply straight away. You finish knowing which jobs fit this tool, which ones do not, and how to notice when a task has failed without anyone telling you.

@app.post("/signup", status_code=201)
def signup(email: str, tasks: BackgroundTasks):
    tasks.add_task(send_welcome, email)
    return {"queued": True}

Declare a BackgroundTasks param, add callables to it. They run once the response has been made.

The caller isn't waiting for the welcome email. That is the entire value. A request that must do a thing slow and inessential can answer at once and do it later.

Worth knowing

Declare a BackgroundTasks parameter and call add_task(fn, *args, **kwargs). The task runs after the response is produced.
Tasks run in the same process, sequentially, in the order added — not concurrently and not on another machine.
A task that raises cannot tell the caller: the response has gone. Catch and log inside the task.
Dependencies can add tasks too, so cross-cutting work does not have to touch the handler.
Anything slow blocks a worker for its duration, and anything in flight is lost if the process restarts.
Use them for short, non-critical, fire-and-forget work. Anything that must not be lost belongs in a real queue.

Background Tasks

Work that should happen after the response has gone. The point at which it needs a real queue instead.

Declare it and add to it

A BackgroundTasks parameter gives you something to schedule on. The task runs after the response is produced.

example_01.pyFastAPI
Output

The response goes first

Ordering is the whole point: the client is answered, then the task runs.

example_02.pyFastAPI
Output

Several tasks, in order

They run one after another, in the order added — not concurrently.

example_03.pyFastAPI
Output

Arguments are passed through

Positional and keyword arguments both work, and the values are captured when the task is added.

example_04.pyFastAPI
Output

A task that raises

The response has already gone, so the failure cannot reach the caller. Catch it inside the task.

example_05.pyFastAPI
Output

They can be added from a dependency

Anything with a BackgroundTasks parameter can schedule work, so cross-cutting jobs need not touch the handler.

example_06.pyFastAPI
Output

Ordering, precisely

The response is made and sent, then the tasks run, in the order they were added, one after another.

Not concurrently. A slow first task delays the second. Both delay nothing that the client can see. But they do occupy the worker.

For a sync handler the tasks run in the threadpool. For an async one they run on the loop. The same rule as endpoints applies to a task's own body. A blocking task added by an async handler blocks the loop, after the response. This Is easy to miss just because nothing appears slow to the caller.

What they are not

This is the part that matters, and it's where people get hurt.

They aren't durable. Tasks live in memory in the current process. If the process restarts. A deploy, a crash, a scale-down. Anything queued is gone, with no record that it existed.

They aren't spread. They run in the process that served the request, on the same machine, consuming its room.

They aren't retried. A task that fails has failed. There's no backoff, no dead-letter queue, no visibility.

They aren't observable. Nothing tracks how many are pending or how long they take unless you build it.

So the honest description is: a convenient way to do a *short, non-key* piece of work after reacting.

When to use a real queue

Move to Celery, RQ, Dramatiq, or a cloud queue when any of these is true.

The work must not be lost. A payment confirmation, an audit record, anything a user would notice the lack of.

The work is slow — more than a second or two. A background task occupies a worker. Enough of them starve the pool just as slow handlers do.

The work should be retried on failure.

The work should scale apart from the web tier, or run on other hardware.

You need to see the queue — depth, failures, delay.

The rule of thumb: a background task is for work whose loss would be an inconvenience. Anything whose loss would be a bug belongs one spot durable.

Good uses: writing a log line, warming a cache, sending a non-key notification, cleaning up a temporary file, firing an analytics event.

Failure

A task that raises can't report to the caller. The caller already has their response. The exception surfaces in the server logs and nowhere else.

So catch inside the task, and log on purpose. A bare exception in a background task is a silent failure by build.

If the work has a real failure the client should learn about, it isn't a background task. It's either part of the request, or a job with a status the client can poll.

The right status code

An endpoint that queues work rather than completing it's a good pick for 202 Accepted rather than 200 or 201.

202 says just what happened: the request was understood and accepted. It isn't finished. If there's anything to poll, the body should say where.

Returning 201 for a thing that hasn't been created yet is a small lie that a client may act on.

From a dependency

Any callable that can declare params can declare BackgroundTasks, which includes dependencies.

That is a tidy way to attach cross-cutting after-the-fact work. An audit trail, a metrics event. To a whole router without touching a single handler. The dependency schedules. The handlers stay unaware.

Mistakes people make with Background Tasks

Treating them as durable. They live in memory in the serving process. A deploy discards all queued, with no record it existed.

Putting a thing key in one. A payment confirmation, an audit record, anything a user would notice missing. Loss is invisible and unrecoverable.

Long-running work. A background task occupies a worker for its duration. Enough of them starve the pool just as slow handlers do.

Letting exceptions escape. The response has gone. The failure reaches the logs and no one else. Catch and log inside the task.

Blocking the loop from an async handler. A synchronous task added by an async endpoint runs on the loop after the response - stalling all, while appearing fast to the caller who already left.

Returning 201 for queued work. It says a thing was created. 202 says it was accepted and isn't finished. This Is what really happened.

The line

A background task is for work whose loss would be an inconvenience. Anything whose loss would be a bug belongs in a durable queue.

Good: a log line, a cache warm, a non-key notification, a temporary file cleaned up, an analytics event.

Not: anything with money in it, anything a user is told happened, anything that must be retried, anything that takes more than a second or two.

The upgrade path is Celery, RQ, Dramatiq or a cloud queue. The moment to take it's when you first find yourself hoping a task didn't get lost.

Where it fits

Background tasks sit between doing the work in the request and running a real queue. The band they occupy is narrower than it first appears.

Above them: anything durable, retried, observable, slow, or scaled apart. That is a queue. Reaching for one isn't over-engineering once the work matters.

Below them: anything the caller needs the result of. That belongs in the request. If it's slow the honest answer is 202 with a thing to poll rather than a background task and a hopeful 200.

What is left is truly handy - the log line, the cache warm, the notification no one will chase - and for that they're just right, cost nothing to adopt. Need no infrastructure at all.

A worked upgrade path

The moment to leave background tasks behind is recognisable. The move is smaller than it looks.

Stage one is what this module shows: tasks.add_task(send_welcome, email). No infrastructure, no config. The work is lost on restart.

Stage two keeps the same call site and changes what it does. The task turns enqueue(send_welcome, email), writing a row to a jobs table or a message to a queue. The endpoint is unchanged. The durability arrives underneath it.

Stage three is a worker process consuming that queue, with retries, backoff and a dead-letter path for what keeps failing.

Writing stage one so the call site is a single function - not five lines of task build inline - is what makes stage two a small change rather than a rewrite of each endpoint.

What the caller should be told

An endpoint that queues work owes the caller two things.

An honest status: 202 Accepted, not 200 or 201, because nothing is finished.

one spot to look, when there's anything to look at. A job id and a GET /jobs/{id} is the standard shape. It turns "we will get to it" into a thing a client can act on.

Without those, the caller assumes completion. That assumption is fine for a log line and wrong for anything they'll ask about later.

Background Tasks: summary

Declare BackgroundTasks, call add_task, and the work happens after the response.

They run in the same process, sequentially, without retries. Are lost on restart. Catch exceptions inside them because no one is listening. Return 202 when queueing rather than completing. And move to a real queue the first time you find yourself hoping a task didn't get lost.

Where to go after Background Tasks

Work that happens once per process rather than once per request. Startup and shutdown. Here A link pool really belongs. Why anything that must happen just once for the use doesn't belong there either.

The honest summary

Background tasks are a small, sharp tool with a narrow band of good uses.

They cost nothing to adopt, need no infrastructure. Drop truly inessential work from the request path. For a log line, a cache warm or a notification no one will chase, they're just right.

They're also in-memory, in-process, sequential, unretried, unobserved and lost on restart. each one of those is fine for the uses above and disqualifying for anything else.

The failure isn't using them. It's using them for a thing that matters and discovering the properties later, often when somebody asks why a confirmation never arrived and there's no record that it was ever attempted.

A closing thought

The value of background tasks is that they exist at all, for free, with no infrastructure.

Most uses have a handful of things that truly shouldn't delay a response and truly don't matter enough to build a queue for. Before this feature the choice was to do them in the request anyway, or to add a broker for a thing trivial.

Knowing just what they promise - which is very little - is what makes them safe to use for that handful. What stops them being reached for when the promise matters.

One more consideration

Background tasks share the process with the requests they follow. This Means they share its limits.

A sync task added by a sync handler runs in the threadpool, occupying a worker that could have served a request. A sync task added by an *async* handler runs on the loop, after the response - stalling every other request in the process while appearing just right fast to the caller who has already left.

That second case is worth watching for. Nothing about it looks slow from outside. The endpoint's delay is fine. All else in the process degrades. The cause is code that runs after the thing you were measuring.

The rule from the async module applies unchanged: if the task blocks, it shouldn't be running on the loop.

Questions people ask about Background Tasks

How do I run something after sending the response? Add a BackgroundTasks parameter and call tasks.add_task(func, arg).

When does the task run? After the response has been sent to the client, in the same process.

What if the task fails? The client has already received success, so the error is not reported to them. Log inside the task.

Can background tasks be async? Yes. An async def task runs on the event loop and a plain def task runs in a thread pool.

When should I use Celery or a queue instead? When the work is long, must survive a restart, needs retries, or should run on another machine.

Can I add a task from a dependency? Yes. Dependencies can declare BackgroundTasks and add to the same shared instance.

Background Tasks in one line

A background task is in-memory, in-process, sequential, unretried and lost on restart - which makes it just right for the log line and just wrong for the confirmation email. The whole skill is telling those apart before rather than after.

The tell that you've crossed the line is simple. If you'd be uncomfortable telling a user "we may have lost this and can't check", the work doesn't belong in a background task. That sentence is just what the feature promises.

A last hands-on note: write the call site as one function - enqueue(fn, *args) rather than task build spread through the handler. If the work later needs a real queue, that is a one-line change in one place instead of an edit to each endpoint that scheduled anything.

Check yourself

0 of 4

Answer without scrolling back up.

  1. When does a background task run?

  2. The process restarts with tasks queued. What happens to them?

  3. A background task raises. What does the caller see?

  4. Which status code best fits an endpoint that queues work?

Cheat sheet

Background Tasks

Add a BackgroundTasks parameter, schedule work after the response leaves, and see the point where this runner needs replacing with a task queue.

FASTAPI · vizlearn.in/fastapi/background_tasks.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.