Skip to main content
FastAPI · lesson 17 of 19
FastAPI advanced Lesson 17 of 19

FastAPI Background Tasks and Middleware

FastAPI BackgroundTasks and middleware, measured: the response beats the task, a failing task, request-ID headers, middleware order, and pure ASGI.

By SkillByExample · Published

fastapi python background-tasks middleware asgi starlette

Work After the Response, and Code Around Every Request

Some work doesn’t belong in the request: sending a welcome email, writing an audit row, resizing an upload. The client shouldn’t wait for it. FastAPI’s BackgroundTasks runs that work after the response has gone out. Middleware is the opposite shape: code that runs around every request, before the route and after it, which is where request IDs and timing headers live.

This lesson measures both: when a background task really runs, what happens when it fails, the order middleware runs in, a header that goes missing on exactly the response you need it on, and where the decorator form of middleware stops being able to see. Outputs come from FastAPI 0.143.0, Starlette 1.7.0 and Uvicorn 0.54.0 on Python 3.12, called with curl 7.85.0. Run each file with uvicorn <file>:app --port 8011 and send requests from a second terminal.

The Response Leaves Before the Task Runs

Add a BackgroundTasks argument to a route and queue functions on it. Save as bg.py:

import time

from fastapi import BackgroundTasks, FastAPI

app = FastAPI()
START = time.perf_counter()


def log(msg: str):
    print(f"{time.perf_counter() - START:6.2f}s  {msg}")


def send_welcome_email(email: str):
    log(f"task: sending to {email}")
    time.sleep(2)  # stands in for a slow SMTP call
    log(f"task: sent to {email}")


@app.post("/signup")
def signup(email: str, tasks: BackgroundTasks):
    tasks.add_task(send_welcome_email, email)
    log("route: returning")
    return {"status": "signed up"}

-w makes curl print how long the whole request took:

$ curl -s -w '  %{time_total}s\n' -X POST '127.0.0.1:8011/signup?email=ada@example.com'
{"status":"signed up"}  0.020826s

The server’s terminal (set PYTHONUNBUFFERED=1 so print output appears straight away):

$ uvicorn bg:app --port 8011
INFO:     Started server process [3672]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
  0.36s  route: returning
INFO:     127.0.0.1:58065 - "POST /signup?email=ada@example.com HTTP/1.1" 200 OK
  0.36s  task: sending to ada@example.com
  2.36s  task: sent to ada@example.com

The client had its answer in 0.02 seconds. The task started only after the access-log line, and finished 2 seconds later. add_task doesn’t call the function; it stores it with its arguments on the response, and Starlette runs it once the response body has been sent. It’s the same process and the same server, with no queue and no worker: a plain def task runs in the thread pool, like a def route, and an async def task runs on the event loop, where a blocking call would stall every other request.

A Task That Fails

Queue two tasks, and make the first one raise. Save as bg_fail.py:

from fastapi import BackgroundTasks, FastAPI

app = FastAPI()


def charge_card(order_id: int):
    raise ValueError(f"card declined for order {order_id}")


def send_receipt(order_id: int):
    print(f"receipt sent for order {order_id}")


@app.post("/orders/{order_id}")
def place_order(order_id: int, tasks: BackgroundTasks):
    tasks.add_task(charge_card, order_id)
    tasks.add_task(send_receipt, order_id)
    return {"order": order_id, "status": "accepted"}
$ curl -s -i -w '\n' -X POST 127.0.0.1:8011/orders/7
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:37:57 GMT
server: uvicorn
content-length: 31
content-type: application/json

{"order":7,"status":"accepted"}
$ curl -s -w '\n' -X POST 127.0.0.1:8011/orders/8
{"order":8,"status":"accepted"}

The traceback in the server terminal is about 40 frames of Uvicorn, Starlette and AnyIO. grep -v '^ ' drops the indented frame lines and keeps the rest:

$ uvicorn bg_fail:app --port 8011 2>&1 | grep -v '^  '
INFO:     Started server process [10692]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
INFO:     127.0.0.1:64960 - "POST /orders/7 HTTP/1.1" 200 OK
ERROR:    Exception in ASGI application
Traceback (most recent call last):
ValueError: card declined for order 7
INFO:     127.0.0.1:64961 - "POST /orders/8 HTTP/1.1" 200 OK
ERROR:    Exception in ASGI application
Traceback (most recent call last):
ValueError: card declined for order 8

The client was told 200 OK and accepted for an order whose card was declined, because the response had already been sent when the task raised. Nothing reaches the client: no 500, no exception handler. Look at what’s missing, too: receipt sent never printed. Tasks on one response run in order, one after another, and an exception stops the rest. The server itself is fine, as order 8 shows. So a background task must handle its own errors, and work that has to happen, such as charging a card, needs a real job queue with retries (Celery, arq, a cloud queue), not BackgroundTasks. Practice task 1 fixes this file.

Middleware: A Timing Header and a Request ID

@app.middleware("http") registers a function that receives every request and a call_next that runs the rest of the app. Save as mw.py:

import time
import uuid

from fastapi import FastAPI, Request

app = FastAPI()


@app.middleware("http")
async def add_timing(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.4f}"
    return response


@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:8]
    response = await call_next(request)
    response.headers["X-Request-ID"] = request_id
    return response


@app.get("/items/{item_id}")
async def read_item(item_id: int):
    return {"item_id": item_id}
$ curl -s -i -w '\n' 127.0.0.1:8011/items/3
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:38:00 GMT
server: uvicorn
content-length: 13
content-type: application/json
x-process-time: 0.0017
x-request-id: 369ae7f0

{"item_id":3}
$ curl -s -i -w '\n' 127.0.0.1:8011/items/3 -H 'X-Request-ID: checkout-42'
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:38:00 GMT
server: uvicorn
content-length: 13
content-type: application/json
x-process-time: 0.0008
x-request-id: checkout-42

{"item_id":3}
$ curl -s -i -w '\n' 127.0.0.1:8011/items/abc
HTTP/1.1 422 Unprocessable Entity
date: Tue, 13 Oct 2026 11:38:00 GMT
server: uvicorn
content-length: 152
content-type: application/json
x-process-time: 0.0003
x-request-id: dd2d9f52

{"detail":[{"type":"int_parsing","loc":["path","item_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]}
$ curl -s -i -w '\n' 127.0.0.1:8011/nope
HTTP/1.1 404 Not Found
date: Tue, 13 Oct 2026 11:38:00 GMT
server: uvicorn
content-length: 22
content-type: application/json
x-process-time: 0.0003
x-request-id: 952a3809

{"detail":"Not Found"}

Both headers are on every response, including the 422 from validation and the 404 for a route that doesn’t exist: middleware wraps the whole app, not individual routes. When the client sends its own X-Request-ID, the middleware passes it through, so one id can follow a request from a load balancer through several services and into every log line. The process time is about a millisecond, since this route does no work. Note the header order: x-process-time was added before x-request-id, so add_timing finished first. That’s a clue to the next section.

The Order Middleware Runs In

Two identical middleware that announce themselves. Save as order.py:

from fastapi import FastAPI, Request

app = FastAPI()


def make_middleware(name: str):
    async def middleware(request: Request, call_next):
        print(f"{name}: before")
        response = await call_next(request)
        print(f"{name}: after")
        response.headers[f"X-{name}"] = "seen"
        return response
    return middleware


app.middleware("http")(make_middleware("First"))
app.middleware("http")(make_middleware("Second"))


@app.get("/")
async def root():
    print("route")
    return {"ok": True}
$ curl -s -i -w '\n' 127.0.0.1:8011/
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:34:25 GMT
server: uvicorn
content-length: 11
content-type: application/json
x-first: seen
x-second: seen

{"ok":true}
$ uvicorn order:app --port 8011
INFO:     Started server process [41464]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
Second: before
First: before
route
First: after
Second: after
INFO:     127.0.0.1:58232 - "GET / HTTP/1.1" 200 OK

Second was registered last and runs first. Each new middleware wraps everything registered before it, so the last one added is the outermost layer: first in on the request, last out on the response. That’s why x-first is set before x-second, and why x-process-time came before x-request-id in mw.py. The decorator @app.middleware("http") and app.add_middleware(...) follow the same rule. In practice, register the middleware that must see everything, such as the request ID, last, so its id exists before any other middleware logs.

The 500 That Has No Request ID

A request id is most useful on the response that went wrong. Save as boom.py:

import uuid

from fastapi import FastAPI, Request

app = FastAPI()


@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = uuid.uuid4().hex[:8]
    print(f"[{request_id}] {request.method} {request.url.path}")
    response = await call_next(request)
    response.headers["X-Request-ID"] = request_id
    return response


@app.get("/boom")
async def boom():
    raise RuntimeError("bug in the route")
$ curl -s -i -w '\n' 127.0.0.1:8011/boom
HTTP/1.1 500 Internal Server Error
date: Tue, 13 Oct 2026 11:34:37 GMT
server: uvicorn
content-length: 21
content-type: text/plain; charset=utf-8

Internal Server Error
$ uvicorn boom:app --port 8011 2>&1 | grep -v '^  '
INFO:     Started server process [16352]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
[297f037e] GET /boom
INFO:     127.0.0.1:58248 - "GET /boom HTTP/1.1" 500 Internal Server Error
ERROR:    Exception in ASGI application
Traceback (most recent call last):
RuntimeError: bug in the route

No x-request-id. The user reports “I got a 500”, and there’s no id to search the logs for. call_next doesn’t turn the route’s exception into a response: it raises it again inside the middleware, so the header line never runs. The exception keeps going out to Starlette’s ServerErrorMiddleware, which is always the outermost layer, outside every middleware you add, and that’s what sent the plain-text 500. Catch it in the middleware instead. Save as boom_fixed.py:

import logging
import uuid

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()
log = logging.getLogger("uvicorn.error")


@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = uuid.uuid4().hex[:8]
    try:
        response = await call_next(request)
    except Exception:
        log.exception(f"[{request_id}] unhandled error")
        response = JSONResponse({"detail": "Internal error", "request_id": request_id}, 500)
    response.headers["X-Request-ID"] = request_id
    return response


@app.get("/boom")
async def boom():
    raise RuntimeError("bug in the route")
$ curl -s -i -w '\n' 127.0.0.1:8011/boom
HTTP/1.1 500 Internal Server Error
date: Tue, 13 Oct 2026 11:34:47 GMT
server: uvicorn
content-length: 51
content-type: application/json
x-request-id: 3589aadf

{"detail":"Internal error","request_id":"3589aadf"}
$ uvicorn boom_fixed:app --port 8011 2>&1 | grep -v '^  '
INFO:     Started server process [40908]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
ERROR:    [3589aadf] unhandled error
Traceback (most recent call last):
RuntimeError: bug in the route
INFO:     127.0.0.1:58263 - "GET /boom HTTP/1.1" 500 Internal Server Error

Now the client sees the id in both the header and the body, and the log line carrying the full traceback starts with the same id. log.exception logs at ERROR level with the traceback attached, through Uvicorn’s uvicorn.error logger so it uses the same format. The body says only “Internal error”: never send the exception message to the client, because it can contain SQL, file paths or secrets.

@app.middleware vs Pure ASGI Middleware

The decorator form is built on Starlette’s BaseHTTPMiddleware. It hands you a Request and a Response, which is convenient, but call_next returns as soon as the response’s headers are ready. Time a streaming response with it. Save as http_timing.py:

import asyncio
import time

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse

app = FastAPI()


@app.middleware("http")
async def timing(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    print(f"{request.url.path} took {time.perf_counter() - start:.3f}s")
    return response


async def three_chunks():
    for i in range(3):
        await asyncio.sleep(0.5)
        yield f"chunk {i}\n"


@app.get("/report")
async def report():
    return StreamingResponse(three_chunks(), media_type="text/plain")
$ curl -s -w 'total %{time_total}s\n' 127.0.0.1:8011/report
chunk 0
chunk 1
chunk 2
total 1.558572s
$ uvicorn http_timing:app --port 8011
INFO:     Started server process [36244]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
/report took 0.002s
INFO:     127.0.0.1:58299 - "GET /report HTTP/1.1" 200 OK

The client waited 1.56 seconds; the middleware reported 2 milliseconds. The body was streamed after call_next had already returned, so a latency dashboard built on this middleware would call every streaming endpoint instant. A pure ASGI middleware is a class whose __call__ receives the raw scope, receive and send, and it doesn’t return until the app has sent its last message. Save as asgi_timing.py, next to http_timing.py:

import time

from fastapi import FastAPI
from fastapi.responses import StreamingResponse

from http_timing import three_chunks


class TimingMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            return await self.app(scope, receive, send)
        start = time.perf_counter()
        await self.app(scope, receive, send)
        print(f"{scope['path']} took {time.perf_counter() - start:.3f}s")


app = FastAPI()
app.add_middleware(TimingMiddleware)


@app.get("/report")
async def report():
    return StreamingResponse(three_chunks(), media_type="text/plain")
$ curl -s -w 'total %{time_total}s\n' 127.0.0.1:8011/report
chunk 0
chunk 1
chunk 2
total 1.552849s
$ uvicorn asgi_timing:app --port 8011
INFO:     Started server process [39988]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
INFO:     127.0.0.1:58307 - "GET /report HTTP/1.1" 200 OK
/report took 1.535s

1.535 seconds, matching what the client saw. The line now prints after Uvicorn’s access log, because the access log is written when the response starts and this middleware waits for the end. The scope["type"] check matters: the same class also receives lifespan and websocket scopes, which the decorator form never sees, and this one passes them straight through.

@app.middleware("http")Pure ASGI class
You work withRequest, Responsescope, receive, send messages
Sees the streamed bodyNo, call_next returns at the headersYes, every send message
WebSocket and lifespanNoYes, by scope["type"]
Adding a headerresponse.headers[...] = ...Wrap send and edit http.response.start (Practice 4)
Use it forShort header and logging workStreaming, timing, anything performance-sensitive

Practice

  1. Fix bg_fail.py: one background task should charge the card and then send the receipt, catch the payment error, log it with its traceback through the uvicorn.error logger, and skip the receipt. For a runnable demo, decline odd order numbers.
Solution

Save as p1.py:

import logging

from fastapi import BackgroundTasks, FastAPI

app = FastAPI()
log = logging.getLogger("uvicorn.error")


def process_order(order_id: int):
    try:
        if order_id % 2:
            raise ValueError(f"card declined for order {order_id}")
        print(f"charged order {order_id}")
    except ValueError:
        log.exception(f"order {order_id}: payment failed, no receipt sent")
        return
    print(f"receipt sent for order {order_id}")


@app.post("/orders/{order_id}")
def place_order(order_id: int, tasks: BackgroundTasks):
    tasks.add_task(process_order, order_id)
    return {"order": order_id, "status": "accepted"}
$ curl -s -w '\n' -X POST 127.0.0.1:8011/orders/7
{"order":7,"status":"accepted"}
$ curl -s -w '\n' -X POST 127.0.0.1:8011/orders/8
{"order":8,"status":"accepted"}
$ uvicorn p1:app --port 8011 2>&1 | grep -v '^  '
INFO:     Started server process [6128]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
INFO:     127.0.0.1:58327 - "POST /orders/7 HTTP/1.1" 200 OK
ERROR:    order 7: payment failed, no receipt sent
Traceback (most recent call last):
ValueError: card declined for order 7
INFO:     127.0.0.1:58328 - "POST /orders/8 HTTP/1.1" 200 OK
charged order 8
receipt sent for order 8

The error is now a log line that names the order, not an anonymous “Exception in ASGI application”. The receipt depends on the charge, so they belong in one task, where the order between them is explicit. The client still sees accepted for order 7: a background task can’t change a response that’s already sent. If the client must know whether the charge worked, do it in the route, or return a job id it can poll.

  1. Write a middleware that returns 401 without calling the route unless the request has X-API-Key: s3cret. Check /reports with and without the key, then check /docs. What does that tell you about middleware versus a dependency for auth?
Solution

Save as p2.py:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()


@app.middleware("http")
async def require_api_key(request: Request, call_next):
    if request.headers.get("X-API-Key") != "s3cret":
        return JSONResponse({"detail": "missing or wrong API key"}, status_code=401)
    return await call_next(request)


@app.get("/reports")
async def reports():
    print("route ran")
    return {"reports": 3}
$ curl -s -w ' %{http_code}\n' 127.0.0.1:8011/reports
{"detail":"missing or wrong API key"} 401
$ curl -s -w ' %{http_code}\n' 127.0.0.1:8011/reports -H 'X-API-Key: s3cret'
{"reports":3} 200
$ curl -s -w ' %{http_code}\n' 127.0.0.1:8011/docs -o /dev/null
 401
$ uvicorn p2:app --port 8011
INFO:     Started server process [25436]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8011 (Press CTRL+C to quit)
INFO:     127.0.0.1:58339 - "GET /reports HTTP/1.1" 401 Unauthorized
route ran
INFO:     127.0.0.1:58340 - "GET /reports HTTP/1.1" 200 OK
INFO:     127.0.0.1:58341 - "GET /docs HTTP/1.1" 401 Unauthorized

Returning a response without awaiting call_next short-circuits the request: route ran printed only once. But middleware applies to everything, so /docs, /openapi.json and any health check are locked too, and the 401 doesn’t appear in the OpenAPI schema. That’s why auth is usually a dependency, attached to the routes or routers that need it. Middleware suits rules that genuinely cover every request.

  1. Test bg.py with TestClient: one test should replace send_welcome_email with monkeypatch and check it was queued with the right address, and a second should measure how long client.post takes with the real task. Run pytest with -s to see the prints.
Solution

Save as test_bg.py next to bg.py:

import time

from fastapi.testclient import TestClient

import bg

client = TestClient(bg.app)


def test_signup_sends_email(monkeypatch):
    sent = []
    monkeypatch.setattr(bg, "send_welcome_email", sent.append)
    r = client.post("/signup", params={"email": "ada@example.com"})
    assert r.json() == {"status": "signed up"}
    assert sent == ["ada@example.com"]


def test_real_task_runs_before_the_client_returns():
    start = time.perf_counter()
    client.post("/signup", params={"email": "bob@example.com"})
    assert time.perf_counter() - start > 2
$ pytest -v --no-header -s test_bg.py
============================= test session starts =============================
collecting ... collected 2 items

test_bg.py::test_signup_sends_email   0.02s  route: returning
PASSED
test_bg.py::test_real_task_runs_before_the_client_returns   0.03s  route: returning
  0.03s  task: sending to bob@example.com
  2.03s  task: sent to bob@example.com
PASSED

============================== 2 passed in 2.29s ==============================

Unlike curl, TestClient waits for the background tasks before post returns, which is what makes sent == [...] checkable straight after the call. The cost is the second test: it took the full 2 seconds of the fake email. monkeypatch.setattr works because the route looks send_welcome_email up in the module when it runs, and pytest puts the original back after the test. Patch out slow or external tasks in tests and assert on what was queued.

  1. Rewrite the request-ID middleware as a pure ASGI class that adds the header by wrapping send. Try it on a route that returns 100 / n, with n=4 and n=0. Does the pure ASGI version fix the missing header on a 500?
Solution

Save as p4.py:

import uuid

from fastapi import FastAPI


class RequestIDMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        request_id = uuid.uuid4().hex[:8].encode()

        async def send_with_id(message):
            if message["type"] == "http.response.start":
                message["headers"] = [*message["headers"], (b"x-request-id", request_id)]
            await send(message)

        await self.app(scope, receive, send_with_id)


app = FastAPI()
app.add_middleware(RequestIDMiddleware)


@app.get("/divide/{n}")
async def divide(n: int):
    return {"result": 100 / n}
$ curl -s -i -w '\n' 127.0.0.1:8011/divide/4
HTTP/1.1 200 OK
date: Tue, 13 Oct 2026 11:36:08 GMT
server: uvicorn
content-length: 15
content-type: application/json
x-request-id: 7888f54b

{"result":25.0}
$ curl -s -i -w '\n' 127.0.0.1:8011/divide/0
HTTP/1.1 500 Internal Server Error
date: Tue, 13 Oct 2026 11:36:08 GMT
server: uvicorn
content-length: 21
content-type: text/plain; charset=utf-8

Internal Server Error

It doesn’t. On the 500 the route raised before sending anything, so no http.response.start passed through send_with_id. The exception went on out to ServerErrorMiddleware, which sits outside this class and sent its own response with the original send. Headers in ASGI are a list of (bytes, bytes) pairs in the http.response.start message, which is why the id is encoded. The type check is left out here because lifespan and websocket scopes never send http.response.start, so the wrapper passes their messages through untouched. To cover 500s, add a try/except around await self.app(...) that sends its own response with the header, as boom_fixed.py does.

Frequently Asked Questions

Does a FastAPI background task run before or after the response is sent?
After. FastAPI attaches the tasks to the response, and Starlette runs them once the last byte of the body has been sent, in the same process. In a measured run the client got its response in 0.02 seconds while a 2-second task was still running. A plain def task runs in the thread pool and an async def task runs on the event loop.
What happens when a FastAPI background task raises an exception?
The client has already received its response, usually a 200, so it never learns about the failure. Uvicorn logs 'Exception in ASGI application' with the traceback, and any tasks queued after the failing one on the same response are skipped. Catch and log errors inside the task, or use a real job queue such as Celery or arq if the work must be retried.
In what order does FastAPI run multiple middleware?
The middleware registered last is the outermost, so it runs first on the way in and last on the way out. With two @app.middleware('http') functions, First then Second, a request prints Second before, First before, the route, First after, Second after. Register the middleware that must see everything, such as request IDs or timing, last.
Why is my middleware header missing from FastAPI 500 responses?
An unhandled exception propagates out of call_next and through your middleware, so the line that sets the header never runs. Starlette's ServerErrorMiddleware, which is always the outermost layer, then builds the plain 'Internal Server Error' response. Wrap call_next in try/except in the middleware, log the exception and return your own 500 response with the header.
What is the difference between @app.middleware('http') and pure ASGI middleware?
@app.middleware('http') wraps Starlette's BaseHTTPMiddleware: you get a Request and a Response, which is simple, but call_next returns as soon as the response headers are ready, so it can't see the body being streamed. A pure ASGI middleware is a class with __call__(scope, receive, send) that wraps the raw messages, so it sees every body chunk and also websocket and lifespan events. Use the decorator for simple header work and pure ASGI for streaming, timing or performance-sensitive code.