🟩 FastAPI

Background Tasks & WebSockets

📖 Lesson 42 ⏱ 50 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Use FastAPI's BackgroundTasks for lightweight fire-and-forget work
  • Understand when to use BackgroundTasks vs 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 signupBackgroundTasks or ARQ
Resize an uploaded imageARQ / Celery
Write an audit log entryBackgroundTasks
Process a 500 MB videoCelery + dedicated worker
Scheduled (cron) jobsAPScheduler / Celery Beat
Fan-out to 10k webhook subscribersARQ / Celery
Concept: 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.

Tip: You can inject 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
shell

Define 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.py

Enqueue 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.py

Start the worker process separately from your API server:

arq worker.WorkerSettings
shell
Concept: ARQ jobs are persisted in Redis. If the web server restarts before a job is processed, the job survives and will be picked up when the worker comes back online. Every enqueued job receives a unique job_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).

Concept: FastAPI uses 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()← clientstr
receive_bytes()← clientbytes
receive_json()← clientdict / list
send_text(data)→ clientstr
send_bytes(data)→ clientbytes
send_json(data)→ clientdict / list
close(code)→ clientclose 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.

Warning: This in-memory manager only works for a single server process. With multiple Uvicorn workers or horizontal scaling, connections on different processes cannot see each other's 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.

Warning: Passing tokens in query parameters risks them appearing in server access logs, browser history, and proxy logs. For production systems prefer the first-message pattern: call 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
The architecture: each worker process has its own local WebSocket list + one Redis subscriber. When any worker publishes a message, Redis delivers it to all subscribers — every worker then fans it out to its local connections. This enables true horizontal scaling of real-time features.

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
Always cancel background tasks in the shutdown phase — uncancelled tasks can delay Uvicorn's graceful shutdown and cause resource leaks. Wrap the 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 BackgroundTasks only 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 send in 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_VIOLATION if 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 lifespan shutdown — use task.cancel() + try/except CancelledError for 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 WorkerSettings with the task registered and max_jobs=5.
  • In main.py, use lifespan to create and close the ARQ pool; expose it on app.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 ConnectionManager to 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/snapshot HTTP 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