FastAPI 101: The Request Lifecycle
FastAPIFoundation

FastAPI 101: The Request Lifecycle

March 29, 202610 min readPART 01 / 18

Every framework has a lifecycle — the path a request travels from arriving at your server to the moment a response leaves. Most developers use FastAPI for years without understanding this path fully, then spend hours debugging a bug that would have been obvious if they did. This is Part 1 of the FastAPI series: the complete request lifecycle, every layer it touches, and the production gotchas that come from misunderstanding any of them.

The full lifecycle at a glance

Before going deep, here's the complete path a request takes through a FastAPI application:

Client (browser / mobile / API consumer)
    │
    │  HTTP request (TCP connection)
    ▼
ASGI Server (Uvicorn / Hypercorn / Gunicorn + UvicornWorker)
    │
    │  ASGI scope dict {type, method, path, headers, ...}
    ▼
FastAPI Application
    │
    ├─► Middleware Stack (each middleware: process request → call next → process response)
    │     ├── CORSMiddleware
    │     ├── AuthMiddleware (custom)
    │     └── LoggingMiddleware (custom)
    │
    ├─► Router (match URL pattern + HTTP method → find route handler)
    │
    ├─► Dependency Injection (resolve Depends() tree, execute in order)
    │     ├── get_db() → yields DB session
    │     ├── get_current_user() → validates JWT, queries user
    │     └── rate_limit_check() → checks Redis counter
    │
    ├─► Route Handler (your function — the actual business logic)
    │     ├── Validate request body with Pydantic
    │     ├── Execute handler
    │     └── Serialize response through response_model
    │
    └─► Response flows back UP through middleware stack
    │
    ▼
ASGI Server sends HTTP response to client

ASGI vs WSGI

FastAPI is ASGI-native. WSGI (used by Flask, Django traditionally) is synchronous and single-request-at-a-time per worker. ASGI (Asynchronous Server Gateway Interface) is the modern Python server interface designed for async-first web frameworks.

WSGI (Flask / Django):
  One request → one thread → blocks until response → thread released
  WebSockets: not supported natively
  HTTP/2: not supported
  Concurrency: via multiple threads or processes only

ASGI (FastAPI / Django 3+):
  One request → coroutine → can yield while waiting for I/O → handles other requests
  WebSockets: first-class support
  HTTP/2: supported (with Hypercorn)
  Concurrency: thousands of concurrent connections per process

The ASGI contract: your application is a callable that receives a scope dict (connection info), a receive callable (read request body), and a send callable (write response). FastAPI wraps all this so you never interact with it directly — but understanding it explains why ASGI servers like Uvicorn exist and what they're actually doing.

# What FastAPI is underneath the abstractions — a bare ASGI app:
async def app(scope, receive, send):
    assert scope['type'] == 'http'
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [[b'content-type', b'application/json']],
    })
    await send({
        'type': 'http.response.body',
        'body': b'{"hello": "world"}',
    })

# FastAPI abstracts this entirely — you just write route functions

Middleware: the onion

Middleware wraps your entire application. Each middleware layer can inspect and modify the request before passing it inward, and inspect and modify the response on the way out. The order matters — the first middleware added is the outermost layer.

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
import time

app = FastAPI()

# Added first → outermost layer → runs first on request, last on response
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourapp.com"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# Custom middleware using @app.middleware decorator
@app.middleware("http")
async def add_timing_header(request: Request, call_next):
    start = time.perf_counter()

    # Process request going IN
    response = await call_next(request)

    # Process response going OUT
    duration = time.perf_counter() - start
    response.headers["X-Process-Time"] = str(round(duration * 1000, 2))
    return response

The execution order for the example above:

Request IN:  CORSMiddleware → timing middleware → route handler
Response OUT: route handler → timing middleware → CORSMiddleware

The middleware swallowed-exception trap

Middleware that wraps call_next in a bare try/except can accidentally suppress exceptions that your exception handlers were meant to catch:

# ❌ This swallows exceptions — your exception_handler never runs
@app.middleware("http")
async def bad_middleware(request: Request, call_next):
    try:
        return await call_next(request)
    except Exception:
        return JSONResponse({"error": "something went wrong"}, status_code=500)

# ✅ Let exceptions propagate — use exception_handler instead
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
    return JSONResponse({"error": str(exc)}, status_code=400)

Dependency injection

FastAPI's dependency injection is one of its most powerful features. A dependency is any callable that FastAPI will call and inject the result into your route function. Dependencies run before your handler, in the order they're needed.

from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session

def get_db():
    """Yields a DB session, closes it when the request is done."""
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

def get_current_user(
    token: str = Header(..., alias="Authorization"),
    db: Session = Depends(get_db)
):
    """Validates JWT, loads user from DB, raises 401 if invalid."""
    user = verify_token_and_load_user(token, db)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid credentials")
    return user

@app.get("/orders")
def list_orders(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    return db.query(Order).filter(Order.user_id == current_user.id).all()

FastAPI builds a dependency graph and resolves it before calling your handler. If two dependencies both depend on get_db, FastAPI calls get_db only once per request and shares the same session — this is request-scoped caching.

Shared mutable state: the race condition

# ❌ Shared mutable state in a dependency — race condition under concurrent requests
class RequestCounter:
    count = 0  # class-level variable, shared across all requests

def get_counter() -> RequestCounter:
    return RequestCounter()  # returns the class, not an instance

# ✅ Request-scoped state — each request gets its own instance
def get_request_state():
    return {"count": 0}  # new dict per call = per request

@app.get("/")
async def root(state: dict = Depends(get_request_state)):
    state["count"] += 1  # safe — not shared
    return state

The request object

FastAPI parses the request automatically based on your function signature. But you can also access the raw Request object when you need more control.

from fastapi import Request
from pydantic import BaseModel

class OrderCreate(BaseModel):
    product_id: int
    quantity: int

# FastAPI auto-parses JSON body into OrderCreate, path param, and query param
@app.post("/users/{user_id}/orders")
async def create_order(
    user_id: int,              # path parameter — from URL
    source: str = "web",       # query parameter — from ?source=mobile
    order: OrderCreate = Body(...),  # request body — JSON parsed + validated by Pydantic
    request: Request = None    # raw request object if you need headers, client IP, etc.
):
    client_ip = request.client.host
    user_agent = request.headers.get("user-agent")
    return {"user_id": user_id, "order": order, "ip": client_ip}

# For form data and file uploads:
from fastapi import File, Form, UploadFile

@app.post("/upload")
async def upload(
    description: str = Form(...),
    file: UploadFile = File(...)
):
    contents = await file.read()
    return {"filename": file.filename, "size": len(contents)}

Response lifecycle

Once your handler returns, FastAPI serializes the return value, validates it against the response_model if set, and sends it through the middleware stack.

from fastapi import Response
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel

class UserResponse(BaseModel):
    id: int
    name: str
    # email NOT included — filtered out by response_model

# response_model filters and validates the output
@app.get("/users/{id}", response_model=UserResponse, status_code=200)
async def get_user(id: int, db: Session = Depends(get_db)):
    user = db.query(User).filter(User.id == id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user  # Pydantic serializes User ORM object to UserResponse shape

# Custom response — bypass Pydantic serialization for performance
@app.get("/users/{id}/export")
async def export_user(id: int):
    data = generate_csv(id)
    return StreamingResponse(
        data,
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=user.csv"}
    )

Background tasks

Run code after the response has been sent to the client. Useful for sending emails, updating analytics, clearing cache — things the client doesn't need to wait for.

from fastapi import BackgroundTasks

def send_welcome_email(email: str):
    # This runs AFTER the response is sent
    email_client.send(email, subject="Welcome!")

@app.post("/users", status_code=201)
async def create_user(user: UserCreate, bg: BackgroundTasks):
    db_user = create_user_in_db(user)
    bg.add_task(send_welcome_email, email=user.email)
    return db_user  # Response sent immediately; email sent after

Important: Background tasks do NOT run in parallel with the response — they run after the response is fully sent. They also run in the same process and event loop, so a slow background task blocks the loop for other requests if it's blocking I/O inside an async def.

Exception handlers

FastAPI catches HTTPException automatically. For custom exceptions, register handlers:

from fastapi import HTTPException
from fastapi.responses import JSONResponse

class InsufficientFundsError(Exception):
    def __init__(self, balance: float, required: float):
        self.balance = balance
        self.required = required

@app.exception_handler(InsufficientFundsError)
async def insufficient_funds_handler(request: Request, exc: InsufficientFundsError):
    return JSONResponse(
        status_code=402,
        content={
            "error": "insufficient_funds",
            "balance": exc.balance,
            "required": exc.required
        }
    )

@app.post("/purchase")
async def purchase(item_id: int, current_user: User = Depends(get_current_user)):
    if current_user.balance < item.price:
        raise InsufficientFundsError(
            balance=current_user.balance,
            required=item.price
        )
    # ... process purchase

Startup and shutdown events

FastAPI provides hooks for code that runs when the application starts and stops — setting up database connection pools, loading ML models, opening Redis connections.

from contextlib import asynccontextmanager
from fastapi import FastAPI

# Modern approach (FastAPI 0.95+) — lifespan context manager
@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: runs before the app starts handling requests
    print("Starting up...")
    await database.connect()
    redis_client = await create_redis_pool()
    app.state.redis = redis_client

    yield  # Application runs here

    # Shutdown: runs after the last request, before process exits
    print("Shutting down...")
    await database.disconnect()
    await redis_client.close()

app = FastAPI(lifespan=lifespan)

# Older approach (still works but deprecated)
@app.on_event("startup")
async def startup():
    await database.connect()

@app.on_event("shutdown")
async def shutdown():
    await database.disconnect()

Use the lifespan approach for new code — it's a standard Python context manager, easier to test, and cleaner. Store application-wide resources in app.state so they're accessible from request handlers via request.app.state.redis.

Quiz

Q1. You have two middleware layers: AuthMiddleware added first, then LoggingMiddleware. A request comes in. In what order do they execute for the request and response?

Request: LoggingMiddleware → AuthMiddleware → handler. The middleware added last wraps the previous ones — it's the outermost layer for the most recently added middleware. Wait — actually it depends on whether you use add_middleware or @app.middleware.

With add_middleware: last added = outermost. So AuthMiddleware added first, LoggingMiddleware added second → LoggingMiddleware is outermost. Request: Logging → Auth → handler. Response: handler → Auth → Logging.

With @app.middleware("http"): decorators wrap in order defined, first defined = outermost. Always verify with a timing trace when order matters.

Q2. Two route functions both use Depends(get_db). Does FastAPI call get_db() once or twice per request?

Once per request. FastAPI caches dependency results within a request by default. If two dependencies or two route parameters both depend on get_db, FastAPI calls it once and shares the result. This ensures they use the same database session within one request — critical for transactions.

You can disable this with Depends(get_db, use_cache=False) if you explicitly need separate sessions.

Q3. A background task is added with bg.add_task(send_email). The route handler returns in 50ms. The email takes 2 seconds to send. When does the client receive the response?

At ~50ms. The response is sent to the client when the handler returns — background tasks run after. The client gets a 201/200 response immediately without waiting for the email.

The email runs in the same process after the response. If send_email is a sync function with blocking I/O (like SMTP via a sync library), it blocks the event loop for 2 seconds during that time, degrading all other concurrent requests. Use an async email library or run it in a thread pool executor for I/O operations.

Q4. Why does FastAPI need ASGI rather than WSGI?

Async-native concurrency and WebSocket support. WSGI is synchronous: one request ties up a thread until fully handled. FastAPI is built on async/await — a single thread can handle thousands of concurrent requests by yielding control while waiting for I/O. WSGI has no protocol for this.

Additionally, WSGI only handles HTTP. ASGI handles HTTP, WebSockets, and other protocols in the same interface, which is why FastAPI's WebSocket support (@app.websocket("/")) works out of the box without any extra layer.

Part 1 done. Next up — Part 2: Async vs Sync in FastAPI. async def vs def: what actually happens under the hood, which one to use when, and the silent killer that destroys throughput when you get it wrong.

← All FastAPI Posts