Okay so if you've built even a couple of FastAPI projects, you've probably run into this weird moment. Your endpoint works great in /docs, everything's fast, you're feeling good... and then the moment it has to call some third-party API or send a notification, your response time jumps from 50ms to literally 3 seconds.
And you're sitting there like "wait, isn't async def supposed to fix this??"
No. It's not. async def just means your endpoint doesn't block other people's requests while it waits. It does NOT mean your request finishes faster. If your customer is staring at a loading spinner because your server is waiting on someone else's webhook, that's still a bad experience for them. async didn't save anyone there.
This is basically the exact problem Celery exists to fix. So in this post, let's actually build this into a real project instead of doing the boring "send a dummy email" example everyone does. We'll use an order-confirmation endpoint from a food-delivery app called Foodie — kind of like what you'd find in any food delivery backend.
Okay but what's actually the problem here
Say you've got a POST /orders/{order_id}/confirm endpoint. Once the order is saved in the database, two more things need to happen:
- Tell the restaurant's POS system — this is a webhook call and it's sometimes slow or just times out.
- Figure out a delivery ETA — a small calculation based on distance and how many riders are around.
Neither of these things should make the customer wait. They don't need to sit there watching a spinner while the restaurant's POS system decides to be slow today. If you just await both of these directly inside your endpoint, you're basically making your user pay for someone else's bad code.
Now you might be thinking "doesn't FastAPI have BackgroundTasks for this?" Yeah it does, but here's the thing — it runs in the same process as your app. No retries. No memory of what happened if your server restarts. No way to check "hey did this actually finish?" later. That's fine for something like logging where you genuinely don't care if it fails. It is NOT fine for "did the restaurant actually get notified, and if it failed, try again 3 times."
That gap is exactly what Celery fills. It's a totally separate worker process that grabs jobs off a queue, retries them when they fail, and lets you check on their status whenever you want — completely separate from the request that started them.
The 3 pieces you need to understand
FastAPI (sends the job) → Broker (Redis/RabbitMQ) → Celery Worker (does the job)
↓
Result Backend (Redis)
↓
FastAPI checks status using task_id
- FastAPI just sends off the task and immediately responds. That's it, that's its whole job here.
- The broker (we're using Redis, it's the easiest for local dev — RabbitMQ if you need more strict delivery guarantees later) holds the queue of jobs waiting to run.
- The worker is a totally separate process you start with
celery -A app.celery_app worker. It doesn't even know FastAPI exists. It just grabs tasks and runs them. - The result backend keeps track of what state each task is in (
PENDING,STARTED,SUCCESS,FAILURE) so you can check on it later.
Here's the big mental shift you need to make: Celery tasks are regular, boring, synchronous Python functions run by a totally different process. Even though your FastAPI app is async. You're not making your slow task "async" — you're literally handing it off to a different worker to deal with.
Step 1: Set up the Celery app
# app/celery_app.py
from celery import Celery
celery_app = Celery(
"foodie",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1",
include=["app.tasks.order_tasks"],
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="Asia/Kolkata",
enable_utc=True,
task_track_started=True,
result_expires=3600,
task_time_limit=120,
task_soft_time_limit=90,
task_acks_late=True,
worker_prefetch_multiplier=1,
task_routes={
"app.tasks.order_tasks.notify_restaurant": {"queue": "orders"},
"app.tasks.order_tasks.compute_delivery_eta": {"queue": "eta"},
},
)Let me actually explain what's going on here instead of you just copy-pasting it blindly:
- Two separate Redis databases (
/0and/1) for the broker and backend — this just keeps queue traffic and result storage from getting mixed up together. Costs nothing, saves you a headache. task_track_started=True— without this, a task jumps straight fromPENDINGtoSUCCESS/FAILURE. You'd never be able to show "hey, this is in progress" on your UI.task_acks_late=True+worker_prefetch_multiplier=1— this combo means a task only counts as "done" after it actually finishes, not the second a worker picks it up. So if a worker crashes halfway through, the job goes back on the queue instead of disappearing forever. Pretty important for something like "notify the restaurant" — you really don't want a worker crash to just silently eat that.task_routes— sendsnotify_restaurantandcompute_delivery_etato different queues. This means later you can scale them separately (like running more workers on theordersqueue during lunch rush) instead of one pool trying to handle everything at once.result_expires=3600— without this, results just sit in Redis forever and pile up. One hour is more than enough time for a client to check a task's result.
Step 2: Write the actual tasks
# app/tasks/order_tasks.py
import random
import time
from celery import shared_task
from celery.utils.log import get_task_logger
from app.celery_app import celery_app
logger = get_task_logger(__name__)
@shared_task(
bind=True,
name="app.tasks.order_tasks.notify_restaurant",
max_retries=3,
default_retry_delay=10,
)
def notify_restaurant(self, order_id: int, restaurant_id: int, items: list[dict]):
"""
Pings the restaurant's order-intake endpoint (or POS webhook) so the
kitchen sees the order instantly, without making the customer's
POST /orders request wait on a flaky third-party call.
"""
try:
logger.info("Notifying restaurant %s for order %s", restaurant_id, order_id)
# Simulate an unreliable POS webhook — this is exactly the kind
# of call you do NOT want blocking the request/response cycle.
if random.random() < 0.3:
raise ConnectionError("restaurant POS webhook timed out")
time.sleep(2) # pretend network latency
return {"order_id": order_id, "restaurant_notified": True}
except ConnectionError as exc:
logger.warning("Retry %s/3 for order %s", self.request.retries + 1, order_id)
raise self.retry(exc=exc)
@shared_task(name="app.tasks.order_tasks.compute_delivery_eta")
def compute_delivery_eta(order_id: int, restaurant_lat: float, restaurant_lng: float,
customer_lat: float, customer_lng: float):
"""
Runs a (deliberately simplified) ETA model — in the real project this
calls a routing API and factors in current rider availability.
Kept as its own task so it can be retried/re-run independently of
the restaurant notification above.
"""
time.sleep(1.5)
distance_km = ((restaurant_lat - customer_lat) ** 2 +
(restaurant_lng - customer_lng) ** 2) ** 0.5 * 111
eta_minutes = round(10 + distance_km * 4, 1)
return {"order_id": order_id, "eta_minutes": eta_minutes}Notice I made two separate tasks here instead of cramming everything into one function. That's on purpose. notify_restaurant needs retry logic because POS systems are flaky and fail randomly. compute_delivery_eta doesn't need retries, but it does need to run alongside the notification, not wait for it to finish first. If you'd shoved both into one task, a POS timeout would randomly delay your ETA calculation for literally no reason, and a bug in your ETA math would block the restaurant notification from ever going out. Keeping them separate means each one can fail, retry, or scale on its own — which is honestly the whole point of using a task queue instead of just awaiting both calls one after another.
That bind=True on notify_restaurant gives you access to self, and that's what lets you call self.retry(exc=exc) to requeue the task using Celery's own retry/backoff logic, instead of you writing some manual for attempt in range(3) loop yourself.
Step 3: Hook it up to FastAPI
# app/main.py
from fastapi import FastAPI
from pydantic import BaseModel
from celery.result import AsyncResult
from app.celery_app import celery_app
from app.tasks.order_tasks import notify_restaurant, compute_delivery_eta
app = FastAPI(title="Foodie")
class OrderItem(BaseModel):
name: str
quantity: int
class OrderCreate(BaseModel):
restaurant_id: int
items: list[OrderItem]
restaurant_lat: float
restaurant_lng: float
customer_lat: float
customer_lng: float
@app.post("/orders/{order_id}/confirm")
def confirm_order(order_id: int, payload: OrderCreate):
"""
The order itself is already saved by this point (Phase 2's DB logic).
This endpoint just fires off the slow, non-critical follow-up work
and returns immediately — the customer sees "Order confirmed" in
milliseconds instead of waiting on a POS webhook + ETA model.
"""
notify_task = notify_restaurant.delay(
order_id, payload.restaurant_id,
[item.model_dump() for item in payload.items],
)
eta_task = compute_delivery_eta.delay(
order_id,
payload.restaurant_lat, payload.restaurant_lng,
payload.customer_lat, payload.customer_lng,
)
return {
"order_id": order_id,
"status": "confirmed",
"notify_task_id": notify_task.id,
"eta_task_id": eta_task.id,
}
@app.get("/tasks/{task_id}/status")
def get_task_status(task_id: str):
result = AsyncResult(task_id, app=celery_app)
response = {"task_id": task_id, "state": result.state}
if result.state == "SUCCESS":
response["result"] = result.result
elif result.state == "FAILURE":
response["error"] = str(result.result)
return responseTwo things I want to point out here:
Why is confirm_order just a plain def and not async def? Because .delay() is synchronous — it just drops a message into Redis and returns almost instantly. There's nothing to await here, so slapping async on this function gets you literally nothing, and honestly could cause problems later if you accidentally add real blocking code inside it. FastAPI automatically runs plain def endpoints in a thread pool, so this is actually the correct way to do it, not something you need to "fix."
The /tasks/{task_id}/status endpoint is what lets your frontend show something like "Notifying restaurant..." then "Restaurant notified ✅" without keeping a connection open the whole time. Your client hits /orders/1/confirm, gets back two task IDs, and then just checks /tasks/{id}/status every couple seconds. (Or later you upgrade this to a WebSocket instead — which is basically what real-time order tracking apps do.)
Actually running this thing
You need three terminals open at once:
# 1. Redis (broker + result backend)
redis-server
# 2. Celery worker — notice the queue names match task_routes above
celery -A app.celery_app worker --loglevel=info -Q orders,eta
# 3. FastAPI
uvicorn app.main:app --reloadHit POST /orders/1/confirm from /docs, copy the notify_task_id from the response, then keep hitting GET /tasks/{that_id}/status. You'll watch it go from PENDING → STARTED → eventually SUCCESS (or FAILURE after 3 retries if the random 30% failure chance decides to mess with you).
Mistakes people actually make with this (myself included)
- Forgetting
task_acks_late: by default, Celery marks a task as "acknowledged" the second a worker starts it, not when it finishes. So if youkill -9a worker mid-task, that job is just... gone. Not retried, not requeued, gone. For something you actually care about — like a payment notification — this is a silent data-loss bug waiting to bite you. - One giant task instead of a few small ones: cramming unrelated work into a single task means one flaky piece (the POS call) decides the retry behavior for everything else bundled with it, including stuff that never needed retrying in the first place.
- Calling
.delay()inside anasync defendpoint that's also doing real async work: this is totally fine,.delay()doesn't block regardless. The actual mistake is going the other way — writing genuinely blocking code inside a Celery task and thinkingasync defwill magically fix it. It won't. The worker process still sits there blocked for however long that call takes. You'd needasyncio.run()explicitly, and even then, only stuff happening inside that one task benefits — the worker still can't go pick up other jobs while it's stuck. - Not setting up a result backend at all: if you genuinely don't need to check on task status (pure fire-and-forget, nobody's polling), skip the backend — it's just extra overhead you don't need. But the second your frontend needs to know "is this done yet?", you want that backend already configured, not something you're scrambling to add during a debugging session at 1am.
Where you can take this further
This pattern scales pretty naturally:
- Celery Beat if you need periodic tasks, like a nightly job that flags restaurants with unusually slow POS response times.
chain()/chord()if you actually wantcompute_delivery_etato run afternotify_restaurantconfirms the kitchen accepted the order, instead of running both at the same time.- Flower (
celery -A app.celery_app flower) gives you a web dashboard over all of this instead of manually poking/tasks/{id}/statusevery time.
But even without any of that extra stuff, the main takeaway is simple: FastAPI's job is to respond fast. The moment something is slow, unreliable, or just doesn't need to finish before you reply to the user — it doesn't belong in the request/response cycle at all. It belongs in a queue.