Search Topics

Search across all FastAPI topics

GitHub

The Event Loop

Under the Hood

One line of code froze your entire API. Every endpoint, every user, everything. Let's understand why.

You add time.sleep(10) to an async endpoint for "testing". Not just that endpoint — your ENTIRE API freezes for 10 seconds. Every single endpoint, every connected user. One line of code took down everything.

terminal
@app.get("/slow")
async def slow_endpoint():
    time.sleep(10)  # "Just for testing!"
    return {"status": "done"}

# Meanwhile, at the SAME time:
GET /health → ... hanging
GET /users → ... hanging
GET /items → ... hanging
GET / → ... hanging

# ALL endpoints frozen. Not just /slow.
# A single time.sleep() blocked the entire event loop.

Question

How can one time.sleep(10) in one endpoint freeze every other endpoint? The answer is the event loop — the single thread that powers all of your async code. Block it, and everything stops.

The Chef Analogy

Think of a chef in a kitchen. A synchronous chef cooks one dish completely before starting the next — if something needs to simmer for 10 minutes, they just stand there waiting.

An async chef (the event loop) is smarter. When dish 1 goes in the oven, they start preparing dish 2. When dish 2 needs to marinate, they check on dish 1. One chef, many dishes, no idle time.

But here's the catch: there's only ONE chef. If that chef gets stuck stirring a pot and can't let go (that's your time.sleep), every other dish burns. Every customer waits. The whole kitchen is frozen.

Event loop picks up task

Your request

Runs until await

Does work, then yields

Switches to next task

While first awaits I/O

I/O completes

Resumes original task

the event loop model

What you just learned

The event loop is a single thread that juggles all your async tasks

When a task hits 'await', it pauses and the loop runs something else

If anything blocks without awaiting (like time.sleep), the entire loop freezes

Event Loop Visualized

Watch how the event loop handles five concurrent requests using a single thread. No parallelism needed — just smart task switching.

def vs async def — What Actually Happens

FastAPI treats these two differently. With def, FastAPI is cautious — it runs your handler in a thread pool so blocking is safe. With async def, FastAPI trusts you — it runs your handler directly on the event loop. Break that trust and you freeze everything.

main.py
# Synchronous — runs in a thread pool (safe to block)
@app.get("/sync")
def get_users():
    users = db.fetch_all()  # Blocks this thread, not the event loop
    return users

# Asynchronous — runs on the event loop (NEVER block!)
@app.get("/async")
async def get_users():
    users = await db.fetch_all()  # Yields to event loop while waiting
    return users

sync vs async execution

What you just learned

def endpoints run in a thread pool — blocking is safe but uses more resources

async def endpoints run on the event loop — lightweight but must never block

The event loop trusts your async code to yield. Break that trust and everything stops.

What Happens at Every await

When your code hits await, the event loop pauses your function and is free to run other tasks. When the I/O completes, the loop picks up right where you left off.

main.py
@app.get("/dashboard")
async def get_dashboard():
    # 1. Event loop starts running this function

    user = await get_current_user()
    # 2. Pauses here → event loop handles other requests
    # 3. User data arrives → resumes right here

    posts = await db.fetch_posts(user.id)
    # 4. Pauses again → event loop handles other requests
    # 5. Posts arrive → resumes right here

    return {"user": user, "posts": posts}
    # 6. Done. Event loop moves to the next task.

Go Deeper: CPU-Bound Work

Not all blocking is I/O. Heavy computation (image processing, ML inference) also blocks the event loop because there's no await to yield. The fix: offload it to a thread pool.

main.py
# BAD: CPU work blocks the event loop
@app.get("/process")
async def process_image():
    result = heavy_computation()  # No await — freezes everything!
    return result

# GOOD: Offload to a thread pool
from fastapi.concurrency import run_in_threadpool

@app.get("/process")
async def process_image():
    result = await run_in_threadpool(heavy_computation)
    return result  # Event loop stayed free the whole time

Insight

Or just use def for the endpoint! FastAPI will run it in a thread pool automatically. No need for the run_in_threadpool dance if your entire endpoint is synchronous work.

Think about it...

If the event loop is single-threaded, how can FastAPI handle multiple requests concurrently?

Hint: Think about what happens between the request arriving and the I/O completing.

Key Points

Single Thread

The event loop runs on one thread — no race conditions, no locks

await = Yield

Each await lets the event loop switch to another task while waiting

I/O Bound = Great

Async shines with network calls, DB queries, file I/O

CPU Bound = Thread Pool

For heavy computation, use run_in_threadpool to avoid blocking

These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.

1
Using time.sleep() in async handlers
Blocking the event loop with synchronous sleep
Don't do this
main.py
import time

@app.get("/slow")
async def slow_endpoint():
    time.sleep(3)  # Blocks the entire event loop!
    # No other request can be processed for 3 seconds
    return {"status": "done"}
time.sleep() is a synchronous call that freezes the entire event loop. During those 3 seconds, no other requests can be processed. Use asyncio.sleep() instead — it yields control back to the event loop.
2
Using requests library in async code
Synchronous HTTP client blocks the event loop
Don't do this
main.py
import requests

@app.get("/fetch")
async def fetch_data():
    # This blocks the event loop!
    resp = requests.get("https://api.example.com/data")
    return resp.json()
The requests library is synchronous — it blocks while waiting for the response. In an async handler, use httpx with AsyncClient instead. It uses await to yield control, letting the event loop process other requests while waiting.