🎯 Learning Objectives
- Use FastAPI's
BackgroundTasksfor lightweight fire-and-forget work - Understand when to use
BackgroundTasksvs a real task queue (Celery / ARQ) - Implement task queues with ARQ and Redis for durable, retriable background jobs
- Understand the WebSocket protocol and how it differs from HTTP
- Accept and manage WebSocket connections in FastAPI with
websockets - Build a real-time broadcast system using a connection manager
- Handle WebSocket authentication, disconnections, and error recovery
1 · Why Background Tasks?
HTTP is a request-response protocol — the client waits for the full response before continuing. Slow work such as sending emails, generating PDFs, transcoding video, or fanning out to webhook subscribers should not block that response. Moving it off the hot path keeps endpoints fast and the user experience snappy.
Background work falls into two broad tiers:
-
In-process (
BackgroundTasks) — lightweight, fire-and-forget, runs inside the same process and memory space as the web server. No persistence: if the server restarts, queued tasks are lost. - Out-of-process (Celery / ARQ / RQ) — jobs are serialised into a durable queue backed by Redis or RabbitMQ, picked up by separate worker processes, and automatically retried on failure. Horizontally scalable.
Use this decision table to pick the right tool:
| Use case | Solution |
|---|---|
| Send a welcome email after signup | BackgroundTasks or ARQ |
| Resize an uploaded image | ARQ / Celery |
| Write an audit log entry | BackgroundTasks |
| Process a 500 MB video | Celery + dedicated worker |
| Scheduled (cron) jobs | APScheduler / Celery Beat |
| Fan-out to 10k webhook subscribers | ARQ / Celery |
BackgroundTasks runs in the same async event
loop as the web server. If a task blocks the event loop (e.g. heavy CPU work) it
degrades request throughput for every other client. Use it only for fast, I/O-bound
fire-and-forget work.
2 · FastAPI BackgroundTasks
Inject the BackgroundTasks parameter into any route function and call
tasks.add_task(fn, *args, **kwargs) to queue work. The tasks execute
after the response has been sent to the client — the client never waits.
import logging
import httpx
from fastapi import FastAPI, BackgroundTasks, Depends
from pydantic import BaseModel, EmailStr
app = FastAPI()
logger = logging.getLogger(__name__)
# ── Task functions ──
async def send_welcome_email(email: str, username: str) -> None:
"""Async task — runs after response is sent."""
async with httpx.AsyncClient() as client:
await client.post("https://api.emailprovider.com/send", json={
"to": email,
"subject": "Welcome!",
"body": f"Hi {username}, welcome aboard!",
})
logger.info("Welcome email sent to %s", email)
def write_audit_log(user_id: int, action: str) -> None:
"""Sync task — runs in threadpool so it doesn't block the loop."""
logger.info("AUDIT user=%d action=%s", user_id, action)
# ── Route ──
class SignupBody(BaseModel):
username: str
email: EmailStr
@app.post("/signup", status_code=201)
async def signup(body: SignupBody, tasks: BackgroundTasks):
user = {"id": 42, "username": body.username, "email": body.email}
# Queue tasks — they run AFTER the response is returned to the client
tasks.add_task(send_welcome_email, body.email, body.username)
tasks.add_task(write_audit_log, user["id"], "signup")
return {"user": user, "message": "Account created"}
main.py
tasks.add_task(fn, *args, **kwargs) accepts both sync and async
functions. Async tasks run directly on the event loop; sync tasks are dispatched to a
threadpool via asyncio.run_in_executor so they cannot block the loop.
Multiple tasks run sequentially in the order they were added.
BackgroundTasks into a dependency
and add tasks there — useful for audit logging shared across many routes:
async def log_request(request: Request, tasks: BackgroundTasks):
tasks.add_task(write_audit_log, request.url.path)
FastAPI merges background tasks queued inside dependencies with those queued in the
route handler automatically.
3 · ARQ: Durable Task Queues with Redis
When jobs must survive server restarts, be retried on failure, or be distributed across many workers, reach for a proper task queue. ARQ is a lightweight async-first queue built on Redis — a natural fit for FastAPI's async ecosystem.
pip install arq redis
shellDefine your task functions and a WorkerSettings class in a dedicated module:
# worker.py
import asyncio
from arq import create_pool
from arq.connections import RedisSettings
REDIS_SETTINGS = RedisSettings(host="localhost", port=6379)
# ── Task functions (must be async) ──
async def send_email(ctx, email: str, subject: str, body: str) -> dict:
"""ctx is the ARQ worker context (contains redis, job_id, etc.)"""
print(f"Sending email to {email}: {subject}")
await asyncio.sleep(0.5) # simulate SMTP
return {"sent": True, "to": email}
async def generate_report(ctx, report_id: int) -> dict:
print(f"Generating report {report_id}...")
await asyncio.sleep(2.0) # simulate heavy work
return {"report_id": report_id, "status": "done"}
# ── Worker class — defines which tasks this worker handles ──
class WorkerSettings:
functions = [send_email, generate_report]
redis_settings = REDIS_SETTINGS
max_jobs = 10 # concurrent jobs
job_timeout = 60 # seconds before a job is considered failed
worker.pyEnqueue jobs from your FastAPI application using the ARQ connection pool:
# main.py — enqueue jobs from FastAPI
from fastapi import FastAPI, Request
from arq import create_pool
from arq.connections import RedisSettings
from contextlib import asynccontextmanager
REDIS_SETTINGS = RedisSettings()
@asynccontextmanager
async def lifespan(app):
app.state.arq = await create_pool(REDIS_SETTINGS)
yield
await app.state.arq.aclose()
app = FastAPI(lifespan=lifespan)
@app.post("/reports")
async def request_report(report_id: int, request: Request):
job = await request.app.state.arq.enqueue_job(
"generate_report", # function name as string
report_id,
)
return {"job_id": job.job_id, "status": "queued"}
@app.get("/reports/{job_id}")
async def check_report(job_id: str, request: Request):
job = await request.app.state.arq.jobs(job_id)
return {"job_id": job_id, "status": job[0].status if job else "not_found"}
main.pyStart the worker process separately from your API server:
arq worker.WorkerSettings
shelljob_id that you
can poll to check its current status (queued, in_progress,
complete, failed).
4 · WebSockets: Protocol Overview
Standard HTTP is half-duplex and connection-oriented per request: the client sends a request, the server returns a response, and the exchange is complete. To receive new data the client must poll again. WebSocket solves this with a persistent, full-duplex channel where either side can push messages at any time.
- HTTP: client sends request → server sends response → connection closes (or keep-alive for reuse)
- WebSocket: client upgrades HTTP connection → persistent full-duplex channel → either side can send at any time
Typical use cases: live chat, real-time dashboards, collaborative editing, multiplayer gaming, live notifications, stock tickers.
The WebSocket lifecycle looks like this:
Client Server
│ GET /ws │
│ Upgrade: websocket │
│ ─────────────────────▶ │
│ 101 Switching Proto │
│ ◀───────────────────── │
│ │ ← connection open
│ {"msg": "hello"} │
│ ─────────────────────▶ │
│ {"msg": "world"} │
│ ◀───────────────────── │
│ [close frame] │
│ ─────────────────────▶ │
│ [close frame] │
│ ◀───────────────────── │
Three message types flow over the wire: text (UTF-8 strings, most commonly JSON), binary (raw bytes for files or images), and ping/pong control frames used for keep-alive heartbeats (handled automatically by the server).
starlette.websockets.WebSocket
— no extra install is needed. The websockets library that powers the
transport is already pulled in as a dependency of uvicorn[standard].
5 · Basic WebSocket Endpoint
Declare a WebSocket route with the @app.websocket() decorator. The
handler receives a WebSocket instance and must call
await websocket.accept() to complete the handshake before sending or
receiving any data.
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept() # complete the handshake
try:
while True:
data = await websocket.receive_text()
await websocket.send_text(f"Echo: {data}")
except WebSocketDisconnect:
print("Client disconnected")
# ── Receiving different message types ──
@app.websocket("/ws/typed")
async def typed_ws(websocket: WebSocket):
await websocket.accept()
try:
while True:
# receive_text(), receive_bytes(), receive_json()
msg = await websocket.receive_json()
event = msg.get("event")
if event == "ping":
await websocket.send_json({"event": "pong"})
elif event == "message":
await websocket.send_json({"event": "ack", "data": msg.get("data")})
except WebSocketDisconnect:
pass
main.py
WebSocketDisconnect is raised automatically when the client closes the
connection — catch it to perform any cleanup. The full set of send/receive methods
available on a WebSocket instance:
| Method | Direction | Payload |
|---|---|---|
receive_text() | ← client | str |
receive_bytes() | ← client | bytes |
receive_json() | ← client | dict / list |
send_text(data) | → client | str |
send_bytes(data) | → client | bytes |
send_json(data) | → client | dict / list |
close(code) | → client | close frame |
6 · Connection Manager: Broadcasting
Real-world apps need to track every active connection so they can broadcast a message
to all of them — for example, a chat room where one client's message goes to
everyone. Encapsulate this logic in a ConnectionManager class.
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from typing import Optional
import json
app = FastAPI()
class ConnectionManager:
def __init__(self):
self.active: list[WebSocket] = []
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
self.active.append(ws)
def disconnect(self, ws: WebSocket) -> None:
self.active.remove(ws)
async def send_personal(self, message: str, ws: WebSocket) -> None:
await ws.send_text(message)
async def broadcast(self, message: str) -> None:
dead: list[WebSocket] = []
for ws in self.active:
try:
await ws.send_text(message)
except Exception:
dead.append(ws)
for ws in dead:
self.active.remove(ws)
async def broadcast_json(self, data: dict) -> None:
await self.broadcast(json.dumps(data))
manager = ConnectionManager()
@app.websocket("/ws/{client_id}")
async def chat(websocket: WebSocket, client_id: str):
await manager.connect(websocket)
await manager.broadcast_json({"event": "join", "client": client_id})
try:
while True:
text = await websocket.receive_text()
await manager.broadcast_json({
"event": "message",
"client": client_id,
"text": text,
})
except WebSocketDisconnect:
manager.disconnect(websocket)
await manager.broadcast_json({"event": "leave", "client": client_id})
main.py
The broadcast method handles stale connections gracefully: any socket
that raises an exception during send is collected and removed from active
after the loop completes, avoiding mutation-during-iteration bugs.
active list. For multi-process broadcast, use Redis
Pub/Sub — publish messages to a Redis channel from any process and have each
worker subscribe and forward to its local connections.
7 · WebSocket Authentication
Browser WebSocket clients cannot attach arbitrary HTTP headers (the
Authorization header is not available via the WebSocket
browser API). Authentication must therefore arrive via one of two channels:
- Query parameter — append the token to the URL:
wss://example.com/ws/secure?token=<jwt> - First message — accept the connection, then require the client to send an auth payload as its very first message
The query-parameter approach is the simplest to implement:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, status
import jwt
app = FastAPI()
SECRET_KEY = "change-me"
ALGORITHM = "HS256"
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except jwt.InvalidTokenError:
return {}
@app.websocket("/ws/secure")
async def secure_ws(
websocket: WebSocket,
token: str = Query(...), # ?token=<jwt>
):
payload = decode_token(token)
if not payload:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
user_id = payload.get("sub")
await websocket.accept()
try:
while True:
data = await websocket.receive_text()
await websocket.send_json({"user": user_id, "echo": data})
except WebSocketDisconnect:
pass
main.py
Close code 1008 Policy Violation signals to the client that the
connection was rejected due to a policy (authentication) failure rather than a
network error.
await websocket.accept()
immediately (the handshake must complete before you can close gracefully), then wait
for the client to send {"event": "auth", "token": "…"} as its first
payload. Validate the token and close with code 1008 if it is absent or
invalid; otherwise proceed with the main message loop.
Redis Pub/Sub for Multi-Process Broadcasting
When you run multiple Uvicorn workers (or scale horizontally), an in-memory
ConnectionManager only reaches connections on the same process.
Redis Pub/Sub routes messages across all processes.
pip install redis asyncio-redis
terminal
# broadcast.py — Redis Pub/Sub connection manager
import asyncio, json, redis.asyncio as aioredis
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from contextlib import asynccontextmanager
CHANNEL = "chat"
class RedisBroadcastManager:
def __init__(self):
self.local: list[WebSocket] = []
self.redis: aioredis.Redis | None = None
self.pubsub = None
async def startup(self):
self.redis = aioredis.from_url("redis://localhost")
self.pubsub = self.redis.pubsub()
await self.pubsub.subscribe(CHANNEL)
asyncio.create_task(self._listener())
async def _listener(self):
"""Forward every Redis message to all local connections."""
async for msg in self.pubsub.listen():
if msg["type"] == "message":
text = msg["data"].decode()
dead = []
for ws in self.local:
try:
await ws.send_text(text)
except Exception:
dead.append(ws)
for ws in dead:
self.local.remove(ws)
async def connect(self, ws: WebSocket):
await ws.accept()
self.local.append(ws)
def disconnect(self, ws: WebSocket):
self.local.remove(ws)
async def publish(self, data: dict):
"""Publish to Redis — reaches all workers."""
await self.redis.publish(CHANNEL, json.dumps(data))
manager = RedisBroadcastManager()
@asynccontextmanager
async def lifespan(app):
await manager.startup()
yield
app = FastAPI(lifespan=lifespan)
@app.websocket("/ws/{client_id}")
async def chat(websocket: WebSocket, client_id: str):
await manager.connect(websocket)
await manager.publish({"event": "join", "client": client_id})
try:
while True:
text = await websocket.receive_text()
await manager.publish({"event": "message", "client": client_id, "text": text})
except WebSocketDisconnect:
manager.disconnect(websocket)
await manager.publish({"event": "leave", "client": client_id})
broadcast.py
Lifespan Events & Startup / Shutdown
Use the lifespan context manager (FastAPI 0.93+) to run code
when the app starts and shuts down — connecting to Redis, warming up ML models,
starting background loops.
from contextlib import asynccontextmanager
from fastapi import FastAPI
import asyncio, logging
logger = logging.getLogger(__name__)
async def periodic_cleanup():
"""Background coroutine that runs for the app's lifetime."""
while True:
logger.info("Running cleanup...")
await asyncio.sleep(60) # every 60 seconds
@asynccontextmanager
async def lifespan(app):
# ── Startup ──
logger.info("App starting up")
task = asyncio.create_task(periodic_cleanup())
app.state.cleanup_task = task
yield
# ── Shutdown ──
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logger.info("App shut down cleanly")
app = FastAPI(lifespan=lifespan)
@app.get("/health")
def health():
return {"status": "ok"}
lifespan.py
await task in try/except CancelledError
to suppress the expected cancellation exception.
Testing WebSockets
FastAPI's TestClient supports WebSocket testing via a context-manager
interface — no running server needed.
# test_ws.py
from fastapi.testclient import TestClient
from main import app # your app with @app.websocket("/ws/{client_id}")
client = TestClient(app)
def test_echo():
with client.websocket_connect("/ws/test-user") as ws:
ws.send_json({"event": "message", "text": "hello"})
data = ws.receive_json()
assert data["event"] == "message"
assert data["text"] == "hello"
def test_disconnect():
with client.websocket_connect("/ws/user-1") as ws:
ws.send_text("ping")
_ = ws.receive_text()
# Connection closes cleanly when the context manager exits
def test_unauthorized():
"""Secure endpoint should reject missing/invalid token."""
with client.websocket_connect("/ws/secure?token=bad-token") as ws:
# Server closes the connection immediately
import pytest
from starlette.websockets import WebSocketDisconnect
with pytest.raises((WebSocketDisconnect, Exception)):
ws.receive_text()
test_ws.py
TestClient.websocket_connect() uses with to manage
the connection lifecycle. Inside the block you can call
ws.send_text(), ws.send_json(),
ws.receive_text(), ws.receive_json() — all synchronous
in tests even though the server is async.
Best Practices
- Use
BackgroundTasksonly for fast, I/O-bound fire-and-forget work — sending a single email, writing one log entry. Anything slow or CPU-heavy belongs in a real task queue. - Use ARQ or Celery for durable jobs — jobs survive server restarts, can be retried on failure, and can be distributed across worker processes.
- Always handle
WebSocketDisconnect— if you don't catch it, the exception propagates and may leave the connection in a broken state without cleanup. - Clean up dead connections in broadcast — wrap each
sendin a try/except and remove connections that fail; otherwise the manager list grows indefinitely. - Use Redis Pub/Sub (or a message broker) when running multiple workers — in-memory managers only work with a single process.
- Authenticate before accepting — close with
WS_1008_POLICY_VIOLATIONif the token is invalid. If you must accept first, validate the token in the first message and close immediately on failure. - Cancel background coroutines in
lifespanshutdown — usetask.cancel()+try/except CancelledErrorfor clean shutdown. - Send periodic pings — browsers and load balancers time out idle WebSocket connections. Send a ping every 20–30 seconds to keep the connection alive.
Exercises
Exercise 1 — Email Queue with ARQ
Build a signup endpoint that offloads email sending to ARQ:
- Define an ARQ task
send_welcome_email(ctx, email, username)that simulates SMTP with a 1-second delay and logs success. - Define
WorkerSettingswith the task registered andmax_jobs=5. - In
main.py, uselifespanto create and close the ARQ pool; expose it onapp.state.arq. POST /signup— create a user dict, enqueue the email task, return 201 with the user and the job id.GET /jobs/{job_id}— return the job status from ARQ.- Start a Redis instance locally and run the worker with
arq worker.WorkerSettings.
💡 Hint — lifespan + enqueue
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from arq import create_pool
from arq.connections import RedisSettings
@asynccontextmanager
async def lifespan(app):
app.state.arq = await create_pool(RedisSettings())
yield
await app.state.arq.aclose()
app = FastAPI(lifespan=lifespan)
@app.post("/signup", status_code=201)
async def signup(body: SignupBody, request: Request):
user = {"id": 1, "username": body.username, "email": body.email}
job = await request.app.state.arq.enqueue_job("send_welcome_email", body.email, body.username)
return {"user": user, "job_id": job.job_id}
Exercise 2 — Real-Time Chat Room
Build a multi-room WebSocket chat system:
- Extend
ConnectionManagerto support named rooms —dict[str, list[WebSocket]]. WS /ws/{room}/{username}— connect to a room; broadcast join/leave/message events only to that room's members.- On message, broadcast
{"event": "message", "room": room, "user": username, "text": text}. - Write two tests: (a) two clients in the same room receive each other's messages; (b) a client in a different room does NOT receive the message.
💡 Hint — room-aware manager
class RoomManager:
def __init__(self):
self.rooms: dict[str, list[WebSocket]] = {}
async def connect(self, ws: WebSocket, room: str):
await ws.accept()
self.rooms.setdefault(room, []).append(ws)
def disconnect(self, ws: WebSocket, room: str):
if room in self.rooms:
self.rooms[room].remove(ws)
async def broadcast_room(self, room: str, data: dict):
import json
dead = []
for ws in self.rooms.get(room, []):
try:
await ws.send_text(json.dumps(data))
except Exception:
dead.append(ws)
for ws in dead:
self.rooms[room].remove(ws)
Exercise 3 — Live Dashboard with Periodic Push
Build a server-push dashboard endpoint:
WS /ws/dashboard— accepts a connection, then every 2 seconds pushes a JSON payload with{"timestamp": "…", "active_connections": N, "random_metric": float}.- Use
asyncio.sleep(2)inside the handler loop. - Handle client disconnects gracefully — stop pushing when the client closes.
- Add a
GET /dashboard/snapshotHTTP endpoint that returns the same metrics once (for clients that don't support WebSockets). - Write a test that connects, receives 3 messages, then disconnects and confirms the server loop exits cleanly.
💡 Hint
import asyncio, json, random
from datetime import datetime, timezone
from fastapi import WebSocket, WebSocketDisconnect
@app.websocket("/ws/dashboard")
async def dashboard(ws: WebSocket):
await ws.accept()
try:
while True:
payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"active_connections": len(manager.active),
"random_metric": round(random.uniform(0, 100), 2),
}
await ws.send_json(payload)
await asyncio.sleep(2)
except WebSocketDisconnect:
pass