Updated Jun 23, 2026

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:

async def app(scope, receive, send):
    assert scope["type"] == "http"

    await send({
        "type": "http.response.start",
        "status": 200,
        "headers": [(b"content-type", b"text/plain")],
    })
    await send({
        "type": "http.response.body",
        "body": b"Hello",
    })

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.

async def app(scope, receive, send):
    assert scope["type"] == "http"
    method = scope["method"]          # "GET"
    path = scope["path"]              # "/notes"

    # Pull the request body in, chunk by chunk.
    body = b""
    more = True
    while more:
        event = await receive()       # an {"type": "http.request", ...} message
        body += event.get("body", b"")
        more = event.get("more_body", False)

    reply = f"{method} {path} ({len(body)} bytes of body)".encode("utf-8")
    await send({"type": "http.response.start", "status": 200,
                "headers": [(b"content-type", b"text/plain")]})
    await send({"type": "http.response.body", "body": reply})

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
$ 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, you await receive() a lifespan.startup event (open your DB pool here), and later a lifespan.shutdown event (close it). It's how an ASGI app runs setup/teardown code.
  • scope["type"] == "websocket" — a long-lived two-way connection. You await receive() incoming messages and await 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:

from fastapi import FastAPI

app = FastAPI()

@app.get("/notes")
async def list_notes():
    return {"notes": ["milk", "bread"]}

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

  1. An ASGI app is one async function the server calls per connection: async def app(scope, receive, send). It's the async sibling of the WSGI callable from Phase 2.
  2. The response is sent, not returned — you await send(...) an http.response.start event (status + byte headers) then an http.response.body event (the bytes). No return of a body.
  3. scope holds the static request (method, path, headers, plus type), while the body arrives as http.request events you pull in with await receive() — looping on more_body, ⚠️ unlike WSGI's single input stream.
  4. 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.
  5. The same scope/receive/send shape carries HTTP, websocket, and lifespan connections — one protocol, many connection types. That generality is the whole reason ASGI exists.
  6. 💡 FastAPI is built on Starlette, an ASGI framework@app.get async endpoints are this exact scope/receive/send machinery 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?

Was this page helpful?