Request Lifecycle: How Web Frameworks Handle a Request · Foundations: From Socket to Response

WSGI, ASGI and the Event Loop

Why one slow endpoint made every endpoint slow in an async service, and how WSGI workers, ASGI event loops and Node.js serve many requests at once.

Scenario

Example app: a Python service was moved from Flask on Gunicorn (WSGI) to an async framework on Uvicorn (ASGI) "for performance". One endpoint calls a legacy partner API with a blocking HTTP client inside an async def handler.

What you see in production

  • When the partner API is slow, every endpoint becomes slow, including /health, which does nothing.
  • Kubernetes liveness probes time out and restart healthy pods.
  • CPU is low; the process is mostly waiting.

The naive code

@app.get("/partner-prices")
async def partner_prices():
    response = requests.get(PARTNER_URL, timeout=10)   # blocking call inside async code
    return response.json()

Reproduce it

An event loop runs many requests on one thread by switching between them whenever one awaits. A blocking call never awaits, so nothing else runs until it finishes. The simulation models a loop with three requests.

One blocking call stalls the whole event loopTry it yourself
def run_loop(requests_):
    # each request is a list of steps: ("await", seconds) yields to the loop, ("block", seconds) holds it
    clock, finished = 0.0, {}
    waiting = {name: (0.0, list(steps)) for name, steps in requests_.items()}  # name -> (ready_at, remaining steps)
    while waiting:
        name = min(waiting, key=lambda n: waiting[n][0])
        ready_at, steps = waiting.pop(name)
        clock = max(clock, ready_at)
        if not steps:
            finished[name] = clock
            continue
        kind, seconds = steps.pop(0)
        if kind == "block":
            clock += seconds                 # the loop thread is stuck; nobody else runs
            waiting[name] = (clock, steps)
        else:
            waiting[name] = (clock + seconds, steps)   # other requests run while this one waits
    return finished

health = [("await", 0.01)]
blocking_partner = [("block", 3.0)]
async_partner = [("await", 3.0)]

for label, partner in [("blocking client", blocking_partner), ("async client", async_partner)]:
    done = run_loop({"partner": partner, "health-1": health, "health-2": health})
    print(f"{label:<16} health finished at {max(done['health-1'], done['health-2']):.2f}s, partner at {done['partner']:.2f}s")

Output

blocking client  health finished at 3.01s, partner at 3.00s
async client     health finished at 0.01s, partner at 3.00s

Root cause

ASGI servers and Node.js run requests concurrently on an event loop: a single thread that switches between tasks only at await points. A blocking call (synchronous HTTP client, time.sleep, heavy CPU work, a synchronous database driver) holds the thread, so every other request on that loop waits.

The fix

  • Use async libraries inside async def handlers (httpx.AsyncClient, async database drivers) and await them.
  • If you must call blocking code, run it in a thread pool (await asyncio.to_thread(...), Starlette's run_in_threadpool), or declare the handler as plain def: FastAPI runs def endpoints in a thread pool. See FastAPI: async and await.
  • Move CPU-heavy work to worker processes or queues.
  • In Node.js the same rule applies: never use synchronous APIs (fs.readFileSync, heavy loops) on the request path (the Node.js event loop).

The models compared:

Model How concurrency works Typical servers
WSGI (PEP 3333) one request per worker process or thread at a time Gunicorn, uWSGI, mod_wsgi
ASGI (spec) many requests per event loop, switching at await; supports WebSockets Uvicorn, Hypercorn, Daphne
Node.js one event loop per process; non-blocking I/O Node.js HTTP server (Express, Fastify, NestJS)
PHP-FPM one request per worker process PHP-FPM, Apache mod_php

Trade-offs

  • Async code serves many slow I/O requests with little memory, but one mistake blocks everything on the loop.
  • Sync (WSGI, PHP-FPM) workers are simpler and isolate slow requests, but need many processes or threads for high concurrency.

Detect it in production

  • Measure event-loop lag (how late scheduled callbacks run); Node.js exposes it via perf_hooks.monitorEventLoopDelay, and asyncio's debug mode logs slow callbacks.
  • If unrelated endpoints slow down together, suspect a blocked loop.

Key points

  • Event loops switch between requests only at await; blocking calls stall everything.
  • Use async libraries, or run blocking code in threads (def endpoints in FastAPI, asyncio.to_thread).
  • WSGI and PHP-FPM isolate requests in workers; ASGI and Node.js multiplex them on a loop.

Practice questions

What is the difference between WSGI and ASGI?

Answer

WSGI is Python's synchronous server interface: each request occupies a worker process or thread until it finishes. ASGI is the asynchronous successor: requests run as coroutines on an event loop and it also supports long-lived connections such as WebSockets.

Why does a blocking call inside an async handler slow down other requests?

Answer

The event loop runs on one thread and switches tasks only at await points. A blocking call holds the thread, so no other request on that loop can progress until it returns.

How does FastAPI run a plain `def` endpoint?

Answer

In a thread pool, so blocking code inside it doesn't block the event loop. async def endpoints run on the loop and must only use non-blocking (awaited) I/O.

Open in the app to mark lessons complete, take notes and track your progress.