skip to content

What does ASGI's scope, receive and send contract allow that a WSGI callable cannot?

level: seniorimportance: should knowfreq 44%

answer

  1. One call and done versus a conversation
  2. Three arguments instead of two
  3. A dict describing the connection, not the message
  4. Two awaitables carrying typed events
  5. Sockets and startup hooks become expressible

basics

~20 s

ASGI replaces WSGI's one synchronous call with an async callable taking scope, a dict describing the connection, plus receive and send awaitables for events. Those events allow awaiting I/O, incremental streaming both ways, WebSockets, and startup and shutdown hooks.

solid answer

~50 s

A WSGI application is a synchronous callable that gets the whole request context up front and hands back one iterable of bytes; it cannot `await`, so a coroutine-based driver has to be run on a thread and each in-flight request occupies a worker. ASGI is `async def app(scope, receive, send)`. `scope` is a dict describing the connection, with a `type` key that is `http`, `websocket` or `lifespan`. `receive` is an awaitable returning the next inbound event — a body chunk, a socket message, a shutdown signal — and `send` is an awaitable that pushes outbound events, first a response-start event carrying status and headers, then one or more body events. Because both directions are event streams on a coroutine, one process can hold thousands of idle connections, stream a response incrementally, serve long-lived sockets, and run startup and shutdown work through the lifespan scope. The price is discipline: one blocking call inside a handler stalls every other connection on that loop.

code

python · 30 lines
python
import asyncio


async def app(scope, receive, send):
    if scope["type"] != "lifespan":
        return
    while True:
        message = await receive()
        if message["type"] == "lifespan.startup":
            print("opening the connection pool")
            await send({"type": "lifespan.startup.complete"})
        elif message["type"] == "lifespan.shutdown":
            print("draining the connection pool")
            await send({"type": "lifespan.shutdown.complete"})
            return


async def main():
    events = [{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}]

    async def receive():
        return events.pop(0)

    async def send(message):
        print("server got", message["type"])

    await app({"type": "lifespan"}, receive, send)


asyncio.run(main())

go deeper

for a junior

Recall the two signatures side by side: a synchronous callable taking environ and start_response, versus a coroutine function taking scope, receive and send. Knowing that only one of them can await is enough at this level.

for a middle

Explain what each of the three arguments carries, that the response is sent as events rather than returned, and why long-lived connections such as WebSockets are expressible under one contract and not the other.

for a senior

Demonstrate migration judgment: streaming a large response as body events, moving pool setup into the lifespan scope, and finding the blocking call that freezes the loop. Be specific about pushing synchronous work off the loop and about what CPU-bound work does not gain.

for a principal

Own the decision itself. Argue when the async model is worth its failure mode, what the synchronous-driver inventory says about feasibility, and why a threaded adapter over an existing application is often the honest answer rather than a rewrite.

### The limitation being lifted PEP 3333's shape is *one call, one response*. The server calls the application, the application returns an iterable of bytes, iteration ends, the request is over. That shape has three consequences. It is synchronous, so the callable cannot `await` — there is nowhere to yield control to an event loop. It is one-way after the head: the application produces output but cannot read further input while producing it, which rules out a bidirectional socket. And it has no lifecycle, so there is no defined moment to open a connection pool at startup or drain it at shutdown. ### The ASGI shape ASGI keeps the "any callable" spirit and changes the signature to a coroutine function of three arguments: ```python async def app(scope, receive, send): ... ``` **`scope`** is a dict describing the *connection*, not a single message: its `type` key is `http`, `websocket` or `lifespan`, and for HTTP it carries the method, path, query string, headers as a list of byte pairs, and client and server addresses. Header names and values are `bytes` here, not the latin-1 native strings WSGI uses — a deliberate simplification. **`receive`** is an awaitable that returns the next inbound event as a dict with a `type` key: a request-body chunk with a flag saying whether more follows, a client disconnect, an inbound socket message, or a lifespan startup or shutdown signal. **`send`** is an awaitable taking outbound events: a response-start event with status and headers, then one or more body events. Nothing is returned from the application; the response *is* the sequence of sends. ### What that buys *Awaiting I/O.* A handler can `await` a database round trip, and the loop runs other connections meanwhile. A single process holds far more concurrent-but-idle connections than a thread-per-request server, because an idle coroutine costs a few kilobytes instead of a thread stack. *Streaming both ways.* The application can read part of a large upload, act on it, and start sending before the upload finishes. *Long-lived connections.* WebSockets and server-sent events are just more `receive`/`send` events under a different scope type, which is why WSGI never grew them. *Lifecycle.* The lifespan scope is the defined place to open a pool at startup and close it on shutdown, replacing the module-import side effects WSGI applications resort to. ### A concrete migration Take a nightly report generator whose internal HTTP endpoint currently runs under WSGI: a request triggers an aggregation over the previous day and returns a large CSV. Moving it to ASGI across a 3-week release train, the pieces that matter are, in order: the endpoint streams rows as body events instead of building one bytes object, so memory stops tracking report size; the pool opens once under lifespan rather than on first request; and — the trap — the aggregation itself is synchronous CPU-and-disk work, so calling it directly inside the coroutine freezes every other connection for its whole duration. It has to go through `asyncio.to_thread` or a `concurrent.futures.ThreadPoolExecutor`, and even then a CPU-bound step gains nothing from the move because the interpreter lock still serialises it. ### When not to migrate ASGI wins when a process spends its time waiting on other services or holding open connections. If the work is CPU-bound, or if the drivers it depends on are synchronous, the async model adds a way to fail (a stalled loop) without adding throughput. An existing WSGI application can be run under an ASGI server through a threaded adapter that calls the synchronous callable off the loop, but that adapter inherits WSGI's ceiling — concurrency is bounded by the thread pool, and no amount of wrapping turns a one-shot response into a stream. ### The judgement to voice ASGI is a strictly larger contract, not a faster one. It is the right answer for long-lived connections, streaming and I/O-bound fan-out; it is a discipline tax everywhere else, and the discipline is single-sentence: nothing blocking may run on the loop.

  • How do you run an existing WSGI application on an ASGI server?
    Through an adapter that presents the ASGI signature, builds an environ dict from the scope, and calls the synchronous callable in a thread so it never blocks the loop. It works, but it inherits WSGI's limits: concurrency is capped by the thread pool, the response is still produced as one iterable, and nothing in the wrapper makes WebSockets or true streaming available.
  • What happens if an ASGI handler makes a blocking call directly?
    It stalls the entire event loop for the duration, so every other connection on that process — including health checks and already-accepted requests — waits. The symptom is latency that scales with concurrency rather than with the slow call. The fix is to push the call off the loop with `asyncio.to_thread` or an explicit `concurrent.futures.ThreadPoolExecutor`, or to replace the synchronous driver.
  • Why does ASGI pass headers as bytes when WSGI passes native strings?
    WSGI inherited CGI's dict of native strings and had to define a latin-1 decode to keep it working on Python 3. ASGI was designed after the str/bytes split, so it just carries what is on the wire: a list of `(name, value)` byte pairs, with no codec detour and no re-encoding dance to remember.

saying these in an interview costs you the question

  • Describes ASGI as simply a faster WSGI
  • Thinks an ASGI application returns the response body
  • Believes async removes the interpreter lock for CPU work
  • Misses that one blocking call stalls all connections
  • Says WebSockets can be served by a WSGI callable
  • Cannot name what scope, receive and send each carry

context