An ASGI App & the Servers
Back in Phase 2 you wrote a complete WSGI app — one function the
server calls per request, handed an environ dict, returning bytes. Phase 4
explained why that shape couldn't go async, and what ASGI replaced it with. Now we make ASGI real the
same way we made WSGI real: by writing the whole thing by hand, with no framework in sight.
The mental model to carry through, and it's a direct echo of the WSGI one: an ASGI app is one
async function the server calls per connection. Instead of (environ, start_response), it takes
three arguments — scope (what the connection is), receive (an awaitable you call to get events
coming in), and send (an awaitable you call to push events out). Match that shape and you have a
web app that can await. Everything FastAPI does is ergonomics layered over this one function — exactly
as Flask was ergonomics over the WSGI callable. Once you've written it yourself, FastAPI stops being
magic.
A bare ASGI app
Here's the whole thing — a working ASGI app, no framework:
assert ==
await
await
What just happened: app is the ASGI callable — an async def function taking scope, receive,
and send. The server calls await app(scope, receive, send) per connection. First we check
scope["type"] is "http" (ASGI also delivers websocket and lifespan connections through this same
function — more below). Then we send the response as two events: an http.response.start carrying
the status and headers, followed by an http.response.body carrying the bytes. Notice the headers are
(bytes, bytes) tuples here, not (str, str) like WSGI — ASGI works in raw bytes on both sides.
💡 Look at what's missing: there is no return. In WSGI you returned the body; here the response is
sent as events through send, not returned. That's the ASGI shape from Phase 4
in the flesh — a response isn't a value you hand back, it's a stream of messages you push out, each one
an await point where the event loop can go do other work. The two-event split (start then body)
is why you can begin a response, then stream the body in chunks later, all without blocking a worker.
Reading the request
scope is to ASGI what environ was to WSGI: a dict the server fills in with everything static about
the connection — the method, the path, the headers. The difference is the body. In WSGI the body was a
single stream you read from environ["wsgi.input"]. In ASGI the body arrives as http.request events
you pull in by await receive() — and it can come in several chunks.
assert ==
= # "GET"
= # "/notes"
# Pull the request body in, chunk by chunk.
= b
= True
= await # an {"type": "http.request", ...} message
+=
=
= f
await
await
What just happened: the method and path come straight off scope — no fishing through HTTP_* keys
like WSGI, they're plain scope["method"] and scope["path"]. The body is different: each
await receive() hands back one http.request event with a body chunk and a more_body flag. We
loop, appending chunks, until more_body is False and we've got the whole body. Then we send the
response back out as the same two events.
⚠️ This is the trap if you're coming from WSGI: the body is not one read. WSGI gave you a single
input stream; ASGI dribbles the body in as a series of receive events, and you must loop until
more_body is false or you'll silently process a half-empty request. The upside is exactly the point of
async — each await receive() is a yield point, so a worker waiting on a slow upload isn't blocked, it's
free to serve other connections.
Running it: uvicorn
A WSGI app needs a WSGI server (gunicorn, uWSGI) to invoke it. 📝 An ASGI app needs an ASGI server —
the three common ones are uvicorn, hypercorn, and daphne. They speak HTTP on the socket and
translate it into the scope / receive / send calls your app expects. Save the app above as
myapp.py and point uvicorn at it:
$ uvicorn myapp:app
INFO: Started server process [12345]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
What just happened: uvicorn myapp:app means "import myapp.py, find the app object, and run it as
an ASGI app." Uvicorn binds a socket on port 8000, accepts HTTP connections, and for each one calls
await app(scope, receive, send) — the exact function you wrote. Curl http://127.0.0.1:8000/notes and
you get your method/path line back.
💡 Hold the contrast next to Phase 3: gunicorn alone is a WSGI
server; uvicorn is an ASGI server. They are not interchangeable — a WSGI server can't drive an
async def app(scope, ...), and an ASGI server can't drive a def app(environ, start_response). In
production you usually combine them: run gunicorn with uvicorn worker processes
(gunicorn -k uvicorn.workers.UvicornWorker myapp:app), behind nginx. Gunicorn gives you the robust
process manager (multiple workers, restarts); the uvicorn workers give you the async event loop. nginx
out front terminates TLS and serves static files. Same layered shape as the WSGI stack — just async-aware
workers in the middle.
A glimpse: lifespan and websockets
📝 Remember the assert scope["type"] == "http" at the top? That guard exists because HTTP is not the
only thing that comes through your app. ASGI delivers other connection types through the same
scope / receive / send function:
scope["type"] == "lifespan"— sent once at startup and once at shutdown. The server calls your app with a lifespan scope, youawait receive()alifespan.startupevent (open your DB pool here), and later alifespan.shutdownevent (close it). It's how an ASGI app runs setup/teardown code.scope["type"] == "websocket"— a long-lived two-way connection. Youawait receive()incoming messages andawait send()outgoing ones, for as long as the socket stays open.
💡 That's the whole reason ASGI exists in one sentence: one protocol shape — scope / receive /
send — handles HTTP requests, websockets, and the app lifecycle alike. WSGI could only ever do one
request-response over HTTP; ASGI's three-argument async contract is general enough to carry all three.
You don't need the details today — just register that your app function is a single door that
everything comes through, and the scope["type"] tells you what kind of connection you're holding.
What FastAPI and Starlette add
Now the reveal. You just wrote a bare ASGI app — scope / receive / send, headers as byte tuples,
the body looped in chunk by chunk, the response pushed out as two events. That is tedious, and nobody
ships it by hand. So what does FastAPI do?
💡 FastAPI is built on Starlette, and Starlette is an ASGI framework — meaning Starlette's
application object is itself an ASGI callable, the same async def app(scope, receive, send) shape you
wrote, with routing, request parsing, and response serialization built on top. When you write this in
FastAPI:
=
return
What just happened: underneath, app is an ASGI application — uvicorn calls it with exactly
scope / receive / send. The @app.get("/notes") decorator does the scope["path"] matching you'd
otherwise hand-write. FastAPI reads the body via await receive() for you, parses it, and turns the
dict you return into the two send events — http.response.start with content-type: application/json,
then http.response.body with the JSON bytes. Your tidy async def endpoint is the bare ASGI machinery
from the top of this page, with all the ceremony automated away.
Map it the same way Phase 2 mapped Flask onto bare WSGI:
| You wrote, by hand | FastAPI / Starlette gives you | What it's doing underneath |
|---|---|---|
assert scope["type"] == "http", scope["path"] branching |
@app.get("/notes") |
the same scope inspection + path matching, registered |
the await receive() body loop |
typed request models, parsed for you | reads and assembles the body events |
two send(...) events with byte headers |
return {...} |
builds the start + body events and serializes JSON |
your async def app(scope, receive, send) |
the FastAPI() object |
is an ASGI callable too — same shape |
That last row is the kicker, and it's the same kicker as WSGI: the app you create with FastAPI() is
itself an ASGI callable, which is exactly why uvicorn can run it. You wrote the bare ASGI app. FastAPI
is the comfortable seat bolted on top. The final phase ties the two halves of this guide together —
WSGI and ASGI, frameworks and servers — into one picture.
Recap
- An ASGI app is one
asyncfunction the server calls per connection:async def app(scope, receive, send). It's the async sibling of the WSGI callable from Phase 2. - The response is sent, not returned — you
await send(...)anhttp.response.startevent (status + byte headers) then anhttp.response.bodyevent (the bytes). Noreturnof a body. scopeholds the static request (method, path, headers, plustype), while the body arrives ashttp.requestevents you pull in withawait receive()— looping onmore_body, ⚠️ unlike WSGI's single input stream.- ASGI apps need an ASGI server — uvicorn, hypercorn, or daphne. gunicorn alone is a WSGI server; in production, run gunicorn with uvicorn workers behind nginx.
- The same
scope/receive/sendshape carries HTTP,websocket, andlifespanconnections — one protocol, many connection types. That generality is the whole reason ASGI exists. - 💡 FastAPI is built on Starlette, an ASGI framework —
@app.getasync endpoints are this exactscope/receive/sendmachinery with routing, parsing, and serialization on top. You wrote the bare app; FastAPI is the ergonomics.
Quick check
Make sure the ASGI shape stuck:
[
{
"q": "How does a bare ASGI app return its response body to the client?",
"choices": [
"It `await send(...)`s an `http.response.start` event then an `http.response.body` event — the response is sent, not returned",
"It `return`s a list of bytes, exactly like a WSGI app",
"It assigns the body to `scope[\"body\"]` before the function ends",
"It calls `start_response(...)` then returns the bytes"
],
"answer": 0,
"explain": "ASGI apps push the response out as events: an `http.response.start` (status + headers) followed by one or more `http.response.body` events. There is no `return` of a body — that streaming-of-events shape is what lets the app stay async."
},
{
"q": "In an ASGI app, where does the request body come from?",
"choices": [
"From `await receive()` events — `http.request` messages whose `body` chunks you loop over until `more_body` is false",
"From `scope[\"body\"]`, fully assembled before the app runs",
"From `environ[\"wsgi.input\"]`, read as one stream",
"From the `send` callable, which both sends and receives"
],
"answer": 0,
"explain": "Unlike WSGI's single input stream, ASGI delivers the body as `http.request` events. You `await receive()` repeatedly, appending each chunk's `body`, until `more_body` is False."
},
{
"q": "What is the relationship between FastAPI and the bare `scope`/`receive`/`send` app?",
"choices": [
"FastAPI is built on Starlette (an ASGI framework); a FastAPI app IS an ASGI callable, with routing, parsing, and serialization layered over the same scope/receive/send machinery",
"FastAPI replaces ASGI with its own faster protocol that uvicorn translates",
"FastAPI is a WSGI framework, so it uses environ/start_response instead",
"FastAPI has nothing to do with ASGI — it runs directly on raw sockets"
],
"answer": 0,
"explain": "FastAPI sits on Starlette, an ASGI framework, so its app object is itself an ASGI callable — which is why uvicorn can run it. Your `@app.get` async endpoint is the bare scope/receive/send machinery with the ceremony automated."
}
]
← Phase 4: Why ASGI Exists · Guide overview · Phase 6: From Protocol to Framework →
Check your understanding
1. How does a bare ASGI app return its response body to the client?
2. In an ASGI app, where does the request body come from?
3. What is the relationship between FastAPI and the bare `scope`/`receive`/`send` app?