How FastAPI BackgroundTasks actually work
BackgroundTasks comes from Starlette and is re-exported by FastAPI. You declare a parameter of type BackgroundTasks in your path operation and call background_tasks.add_task(send_welcome_email, user.email). FastAPI attaches the queued callables to the response. After the response body has been sent to the client, the same worker process runs them in the order they were added. A plain def function runs in the threadpool. An async def function is awaited directly on the worker's event loop.
All of that state lives in process memory. Nothing is written to Redis, a database, or disk. There is no retry if the function raises an exception, and no record that the task existed beyond your own logs. Any of the following removes the process and discards every task it had not yet finished, without an error:
- a deploy that replaces pods
- an autoscaler scale-down
- a crash
- the kernel's OOM killer
"After the response" also does not mean "off the critical path." The client stops waiting, but the work still runs inside the same worker as other requests:
- Blocking calls in async tasks. If an
async deftask makes a blocking call, such as a synchronous HTTP client ortime.sleep, it stalls the event loop. Every concurrent request on that worker stalls with it. - Sync tasks in the threadpool. Sync tasks occupy threads from the same pool FastAPI uses for sync endpoints and sync dependencies. A slow batch of them leaves fewer threads for incoming requests.
- CPU-heavy work. Work like image resizing competes for the GIL whichever path it takes.
In each case, tail latency rises for unrelated endpoints, even though the endpoint that queued the task looked fast.
import time
from fastapi import BackgroundTasks, FastAPI
from pydantic import BaseModel
app = FastAPI()
class Signup(BaseModel):
email: str
def send_welcome_email(email: str) -> None:
# Plain def: runs in the threadpool after the response is sent.
# It holds a pool thread that sync endpoints also need.
time.sleep(2) # stand-in for a slow SMTP call
print(f"sent welcome email to {email}")
async def record_signup_bad(email: str) -> None:
# async def: awaited on the worker's event loop.
# time.sleep blocks the loop, so every request on this worker stalls.
time.sleep(2)
print(f"recorded signup for {email}")
@app.post("/signup")
async def signup(body: Signup, background_tasks: BackgroundTasks):
# Tasks are kept in process memory and run in order once the response is sent.
# A deploy, scale-down, crash, or OOM kill drops them, with no retry and no record.
background_tasks.add_task(send_welcome_email, body.email)
background_tasks.add_task(record_signup_bad, body.email)
return {"status": "ok"}
@app.get("/health")
async def health():
# While record_signup_bad sleeps, this request waits on the same blocked loop.
return {"ok": True}
A minimal FastAPI endpoint that queues two background tasks: a sync one that holds a threadpool thread, and an async one that blocks the event loop and delays unrelated requests on the same worker.
What an external async task queue adds
An external queue moves that work out of the web process. The request handler only serializes a message and hands it to a broker, which stores it until a worker process picks it up.
In-process:
client -> [ web process: handler -> response -> BackgroundTasks ]
External queue:
client -> [ web process ] --enqueue--> [ broker ] --deliver--> [ worker pool ]
(returns quickly) (holds messages) (scales on its own)
This changes three things:
- Persistence. A message waiting in the broker does not depend on the web process. A web restart no longer drops queued work.
- Independent scaling. You size workers to queue depth and task cost, and you size the web tier to request load. CPU-heavy tasks stop affecting API latency.
- Acknowledgement semantics. Whether a task survives a worker crash depends on when the worker acknowledges the message:
- If the worker acknowledges before execution and then dies, the task is lost.
- If it acknowledges after completion, the broker redelivers the task, so it may run twice.
The queue also adds costs:
- A new failure domain. The broker can be slow or unavailable.
- Every enqueue call needs a timeout.
- Each endpoint needs an explicit decision about what happens when the enqueue fails. It can return an error, or it can degrade, for example by skipping a non-critical email.
- Separate deployment. Workers need their own deployment and their own health checks. A worker can be running but stuck, so checking that the process exists is not enough.
- Graceful draining. On shutdown, a worker should stop taking new messages and finish the ones it already holds before it exits.
- Version skew. During a rolling deploy, new web code can enqueue messages with a different signature that old workers still consume. The reverse can happen too. Message formats therefore need the same compatibility discipline as an API.
IN-PROCESS (BackgroundTasks)
+--------------------------------------------------+
| web process |
| request -> handler -> response -> task runs |
| (a restart or crash drops pending tasks) |
+--------------------------------------------------+
EXTERNAL QUEUE
+-------------+ enqueue +-------------+ deliver +--------------+
| web tier |----------->| broker |---------->| worker pool |
| handler | (timeout) | persists | | worker 1..N |
| returns | | messages |<----------| scales on |
| quickly | | | ack | queue depth |
| | | | | drains on |
| | | | | shutdown |
+-------------+ +-------------+ +--------------+
| ^ |
| broker down? | unacked message | crash
v | is redelivered v
fail fast (5xx) or degrade +--------------------------+
(e.g. skip a non-critical email)
ACK TIMING:
ack before run, then crash -> task lost
ack after run, then crash -> task redelivered (may run twice)
ROLLING DEPLOY:
web v2 may enqueue messages that a still-running v1 worker cannot handle
The in-process path runs tasks inside the web process, while an external queue routes work from web to broker to an independently scaled worker pool, adding enqueue timeouts, acknowledgement-driven redelivery, draining and version-skew concerns.
ARQ: an asyncio-native Redis queue
ARQ takes a narrower approach than Celery. A job is a plain async def coroutine whose first argument is a context dict, and the worker runs those coroutines on a single asyncio event loop. One worker process can therefore keep many I/O-bound jobs in flight at once without threads or a process pool. Redis is the only backend. It stores the queue, the deferred jobs and the results.
A typical setup has three parts:
- The job function. It takes the context dict first, then its own arguments.
- A
WorkerSettingsclass. It lists the job functions and the Redis connection settings. It also holds startup and shutdown hooks, which can open anhttpx.AsyncClientor an async database pool and place it in the context, along with any cron-style schedules. - The FastAPI route. It awaits
enqueue_jobon a Redis pool created at application startup. Optional arguments delay execution, and a caller-supplied job ID prevents duplicate enqueues.
Raising ARQ's retry exception reschedules a job with a delay, up to a configured maximum number of tries.
This model suits teams whose codebase is already async. Task code can reuse the same clients, connection pools and helper functions as the web app. Sync-first task libraries make you write wrappers or duplicate that code.
The limits follow from the same design:
- Redis is mandatory. You cannot swap in another broker.
- The ecosystem is small. There are few integrations and few monitoring dashboards compared with Celery.
- Durability depends entirely on Redis configuration. Without AOF or RDB persistence, a Redis restart drops every queued job. A
maxmemoryeviction policy other thannoevictionlets Redis silently delete job keys when memory is under pressure.
from contextlib import asynccontextmanager
from datetime import timedelta
import httpx
from arq import Retry, create_pool
from arq.connections import RedisSettings
from arq.cron import cron
from fastapi import FastAPI, Request
REDIS = RedisSettings(host="redis.internal", port=6379) # placeholder host
async def send_webhook(ctx, order_id: int):
# Reuse the shared async client opened in startup() instead of one per job
resp = await ctx["http"].post("", json={"id": order_id})
if resp.status_code >= 500:
# Reschedule with a growing delay; ARQ gives up after max_tries
raise Retry(defer=ctx["job_try"] * 10)
async def nightly_cleanup(ctx):
pass # placeholder for periodic housekeeping
async def startup(ctx):
ctx["http"] = httpx.AsyncClient(timeout=10)
async def shutdown(ctx):
await ctx["http"].aclose()
class WorkerSettings: # start with: arq app.WorkerSettings
functions = [send_webhook]
cron_jobs = [cron(nightly_cleanup, hour=3, minute=0)]
on_startup, on_shutdown = startup, shutdown
redis_settings = REDIS
max_tries = 5
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.arq = await create_pool(REDIS) # one pool for the app's lifetime
yield
await app.state.arq.close()
app = FastAPI(lifespan=lifespan)
@app.post("/orders/{order_id}/notify")
async def notify(order_id: int, request: Request):
# Fixed _job_id dedupes repeat clicks: enqueue_job returns None if the ID exists
job = await request.app.state.arq.enqueue_job(
"send_webhook", order_id, _job_id=f"notify-{order_id}", _defer_by=timedelta(seconds=30))
return {"job_id": job.job_id if job else None}
A single module that defines an ARQ job reusing a shared httpx.AsyncClient, retries it with Retry, schedules a cron job in WorkerSettings, and enqueues a deferred, deduplicated job from a FastAPI route through a Redis pool created at startup.
Celery: the mature, heavier option
Celery takes a different approach from ARQ. It separates the moving parts and makes each one configurable. The broker carries messages and is commonly RabbitMQ or Redis. An optional result backend stores return values and task states. Tasks can be routed to named queues so slow report generation never starves password-reset emails. celery beat schedules periodic work, and canvas primitives (chains, groups, chords) compose multi-step workflows. Its monitoring and operations tooling has been in production use for many years. Celery is the right choice when you need several of these at once, have non-async Python workers already, or want RabbitMQ's delivery guarantees.
Each of those parts needs tuning. With the default early acknowledgment, a worker that crashes mid-task loses the task. Setting acks_late makes the broker redeliver it instead, which means tasks may run twice. A high worker_prefetch_multiplier lets one worker hoard long tasks while others sit idle. On Redis, a task that outlives the visibility_timeout is redelivered to a second worker while the first is still running it. Beat must run as exactly one instance, or scheduled jobs fire twice.
Celery tasks are synchronous functions. Reusing async FastAPI code means calling asyncio.run() inside each task, or keeping parallel sync and async database and HTTP clients that drift apart over time.
Result backends grow without bound unless you set result_expires. For user-visible status, a jobs table in your own database is usually clearer.
A typical sketch has three parts:
- Define
generate_report(report_id)with@celery_app.task(acks_late=True). - In the route, insert a job row, then call
generate_report.apply_async(args=[report_id], queue="reports"). - Return
202with the row's ID.
Note that apply_async is a blocking network call, so it briefly blocks the event loop inside an async def route.
import asyncio
from celery import Celery
from fastapi import FastAPI
celery_app = Celery("app", broker="redis://localhost:6379/0")
celery_app.conf.update(
task_acks_late=True, # redeliver if a worker dies mid-task, so tasks must be idempotent
worker_prefetch_multiplier=1, # stop one worker from hoarding long tasks
broker_transport_options={"visibility_timeout": 3600}, # Redis: must exceed your longest task
task_ignore_result=True, # job state lives in our own jobs table, not a result backend
)
async def insert_job_row(status: str) -> int:
... # INSERT INTO jobs (status) VALUES (...) RETURNING id, using your async DB client
async def build_report(report_id: int) -> None:
... # shared async code: set status=running, do the work, set status=done or failed
@celery_app.task(name="reports.generate")
def generate_report(report_id: int) -> None:
# Celery tasks are sync. Bridge to the async code once instead of keeping a parallel sync client.
asyncio.run(build_report(report_id))
app = FastAPI()
@app.post("/reports", status_code=202)
async def create_report() -> dict:
report_id = await insert_job_row(status="queued")
# Route to a dedicated queue so slow reports never delay password-reset emails
generate_report.apply_async(args=[report_id], queue="reports")
return {"job_id": report_id}
# Worker: celery -A app worker -Q reports --concurrency=4
This example configures Celery with late acks, prefetch of 1 and a Redis visibility timeout, and defines a sync task that bridges to async code. The FastAPI route records a job row, sends the task to a named queue and returns 202 with the job ID.
Designing tasks that survive retries and redelivery
Assume any task can run more than once. A worker can finish the side effect and then crash before acknowledging, so the broker redelivers the message. With Redis-backed queues, a job that runs longer than the visibility timeout or acknowledgement deadline can be handed to a second worker while the first is still running. Retries after a timeout repeat work that may have partly succeeded. Exactly-once delivery is not something you can rely on. Exactly-once effects are something you design for.
Make side effects idempotent
- Dedup keys: derive a key from the business operation, such as
invoice:{id}:email. Record it under a unique constraint before acting, and skip the work if the key already exists. Pass the same key to payment or email providers that accept one. - Upserts: write results with insert-or-update keyed on the entity ID, so a second run overwrites instead of duplicating.
- Check state first: if the report row is already
completed, return early.
Pass IDs, not objects
Send primary keys and small JSON-serializable values. ORM instances get pickled with stale state or fail to serialize. Request-scoped resources like sessions and clients are bound to a request that no longer exists. FastAPI's Depends and request.state do not run in the worker, so open a fresh DB session and HTTP client inside the task.
The transactional enqueue problem
If you enqueue before the commit, the worker may read the row before it exists. If you enqueue after the commit, a crash between the two loses the task. Enqueue-after-commit is acceptable when an occasional loss can be reconciled. When it can't, use an outbox: write the task row in the same transaction, then have a relay publish it to the queue.
Choosing between BackgroundTasks, ARQ, and Celery
Whichever enqueue pattern you pick, the queue choice comes down to what happens when a task fails or the process dies.
Use BackgroundTasks for short, best-effort work you can lose, such as non-critical logging, metrics flushes, or cache warming. The task runs in the same process after the response is sent. A worker restart, deploy, or OOM kill drops it with no record. If a user or downstream system expects the result, it does not belong here.
Use ARQ when the service is already async-first and needs durable jobs and retries without much new infrastructure. Your existing async clients work unchanged inside tasks, and the only new dependency is Redis.
Use Celery when you need workflow composition, a broker other than Redis, several routed queues, or a mix of sync-heavy and CPU-bound tasks. It is also the safer choice when your team already operates it and has dashboards and runbooks.
| BackgroundTasks | ARQ | Celery | |
|---|---|---|---|
| Durability | None, in-process | Persisted in Redis | Persisted in broker; tune ack timing |
| Retries | None built in | Built in | Built in, configurable backoff |
| Scheduling | None | Cron-style jobs | Periodic tasks via beat |
| Async support | Native | Native asyncio | Primarily sync tasks |
| Backends | None | Redis only | RabbitMQ, Redis, others |
| Workflow primitives | None | Minimal | Chains, groups, chords |
| Monitoring ecosystem | Your app logs | Limited | Mature (e.g. Flower) |
| Infrastructure | None | Redis + workers | Broker, workers, optional result backend |
| Avoid when | Work must not be lost | You need multiple brokers or complex workflows | The service is small and async-only, and ops capacity is thin |
Testing, local development, and impact on cost and engineering time
Testing each option
BackgroundTasks are the easiest to test. With TestClient, Starlette runs the queued tasks after the response is built and before the call returns. You can post to the endpoint and then immediately assert on the side effect, such as a row written or a fake email client that was called. You don't need to sleep or poll.
For ARQ and Celery, keep task logic in plain functions and test them as units. An ARQ task is an async function that takes a ctx dict, so you can call it directly with a stub context that holds your fake clients. A Celery task can be invoked synchronously in the test process the same way. Then add at least one integration test that runs against a real Redis or broker, either in a container or a CI service. Eager or in-process modes skip serialization, routing, acknowledgement, and retries, and those are where most queue bugs live. A task can pass in eager mode and still fail on a real worker because its arguments don't serialize.
Cost and engineering time
BackgroundTasks costs nothing to run, but it can lose work silently on a deploy or crash. ARQ adds Redis and a worker deployment. Celery adds a broker, possibly a result backend, beat for scheduling, and tuning of concurrency and prefetch.
You save time by choosing the least infrastructure that still meets your durability needs. ARQ lets you reuse your existing async clients. Celery gives you routing, scheduling, and monitoring that you would otherwise have to build. You lose time when a lightweight choice later forces you to debug lost jobs, or when tasks that aren't idempotent produce duplicate side effects.
Alternatives to consider
Dramatiq, RQ, and Taskiq
Dramatiq fills roughly the same role as Celery with a smaller API surface. It supports RabbitMQ or Redis as a broker and has built-in retries with backoff. Teams that find Celery's configuration surface excessive often land here.
RQ is a synchronous Redis queue. Its model is plain Python functions and a worker process. That simplicity is its appeal, but it fits poorly when your task code is already async and shares clients with your FastAPI app.
Taskiq sits closer to ARQ. It is asyncio-native, supports several brokers, and borrows FastAPI-style dependency injection. Consider it if you like ARQ's model but don't want to be locked to Redis.
A queue table in your existing database
If you already run PostgreSQL, you can store jobs in a table. Workers claim rows with SELECT ... FOR UPDATE SKIP LOCKED. The main advantage is transactional enqueueing: the job row commits or rolls back together with the business write that created it. This removes the case where an order is saved but its confirmation-email job is lost because the broker call failed after the commit.
The tradeoffs are polling load and table bloat on your primary database. This approach suits moderate throughput, not high-volume fan-out.
Managed queues and durable workflow engines
Your cloud provider's managed queue removes broker operations from your workload. In exchange, you write the worker loop yourself and accept that provider's delivery semantics.
For long, multi-step processes that must resume after a crash partway through, consider a durable workflow engine. These engines persist each step's result, so a restart continues from the last completed step instead of rerunning the whole task. That is a different problem from fire-and-forget jobs, and these engines carry far more operational weight than any queue discussed here.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to fastapi background tasks, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.