DEV Community

Cover image for One Line of Code Froze My Entire FastAPI Server
Anas Issath
Anas Issath

Posted on Originally published at levelup.gitconnected.com

One Line of Code Froze My Entire FastAPI Server

A debugging war-story about async def, a blocking call, and the event-loop trap that makes a fast framework crawl.

One Call. Total Freeze.

The alert came in at the worst possible time, the way they always do. Our FastAPI service (the one we'd migrated to specifically because it was supposed to be fast and async and handle tons of concurrent requests) was timing out. Not crashing. Not erroring. Just… hanging. Requests that normally returned in 40 milliseconds were sitting there for ten, twenty, thirty seconds, then dying.

The weird part: CPU was near idle. The database was fine. Memory was fine. The server wasn't working hard. It was just somehow not doing anything. A fast framework, sitting on its hands, while requests piled up behind it.

It took me most of a day to find it, and the cause was a single line that looked completely innocent. This is the story of that line, why it did what it did, and the thing about async def that I wish I'd understood before I ever wrote it. If you use FastAPI, this bug is waiting for you too.


The Symptom That Made No Sense

Here's what was so confusing. The endpoint under load looked like this (simplified, but this is the shape):

import requests
from fastapi import FastAPI

app = FastAPI()

@app.get("/profile/{user_id}")
async def get_profile(user_id: int):
    user = await db.fetch_user(user_id)                              # fast, async
    avatar = requests.get(f"https://avatars.example.com/{user_id}")  # the villain
    return {"user": user, "avatar": avatar.json()}
Enter fullscreen mode Exit fullscreen mode

In testing, one request at a time, it was perfectly fine. The external avatar call took maybe 200ms, the endpoint returned, everyone was happy. Every unit test passed. It sailed through code review (mine included; I wrote it).

But in production, under real concurrent traffic, something bizarre happened: it wasn't just the /profile/ endpoint that got slow. Every endpoint got slow. A totally unrelated health-check endpoint that did nothing but return {"status": "ok"} was also timing out.

How could one slow endpoint make a do-nothing endpoint hang? That made no sense to me, and chasing that question is what eventually led me to the real lesson.


The Thing Nobody Told Me About async def

Here's what I didn't understand, and what turns out to be the whole ballgame with FastAPI.

FastAPI runs on an event loop: a single-threaded manager that juggles many requests at once by one rule. While one request is waiting on something slow (a database, an API), it hands control back to the loop so the loop can work on other requests. That's how one process serves thousands of concurrent connections. It's cooperative: every request has to politely yield while it waits, so others can run.

The word await is how a request yields. await db.fetch_user(...) means "I'm going to wait for the database. Loop, go do something else, wake me when it's ready." While that request waits, the loop serves other requests. Beautiful.

But here's the trap. When you mark an endpoint async def, FastAPI runs it directly on the event loop. And the event loop is single-threaded. So if your async def code does something that blocks (something that takes time but doesn't await and doesn't yield control), it doesn't just block that request. It freezes the entire event loop.

Every other request the loop was juggling is now stuck, because the one thread that runs all of them is busy. It isn't yielding; it's just waiting on a blocking call with no way to hand control back.

That's why my health check hung. It wasn't slow itself. It was waiting in line behind a frozen event loop.


The Villain, Identified

Look back at that line:

avatar = requests.get(f"https://avatars.example.com/{user_id}")
Enter fullscreen mode Exit fullscreen mode

requests.get() is a synchronous, blocking call. It makes an HTTP request and blocks: the thread sits there, fully occupied, until the response comes back 200ms later. There's no await. It never yields control.

And it was sitting inside an async def endpoint, running directly on the event loop.

So every time a /profile/ request hit that line, the entire server's event loop froze for 200 milliseconds, not just for that user but for everyone. One request, one blocking call, and the single thread that serves all requests was held hostage.

Under light traffic you'd never notice; 200ms freezes are invisible when they're rare. But under load, with dozens of /profile/ requests a second, the event loop was frozen essentially all the time. The "fast async framework" had been quietly reduced to processing requests one… at… a… time, with everyone else stacked up waiting.

The framework wasn't slow. I had handcuffed it.

The cruelest part: it was faster before. Our old sync framework would've handled this fine, because it used a pool of threads. One blocking call tied up one thread, and the others kept serving. By moving to async and then putting a blocking call on the event loop, I'd taken a framework built for concurrency and forced it into single-file.


The Fix (and the Three Ways to Do It Right)

Once I understood the cause, the fix was easy. There are three legitimate options, depending on what you've got.

Option 1: Use a real async library and await it

The cleanest fix: replace the blocking library with an async one that yields properly. requests → httpx (async), a sync DB driver → an async one.

import httpx

@app.get("/profile/{user_id}")
async def get_profile(user_id: int):
    user = await db.fetch_user(user_id)
    async with httpx.AsyncClient() as client:
        avatar = await client.get(f"https://avatars.example.com/{user_id}")  # now yields!
    return {"user": user, "avatar": avatar.json()}
Enter fullscreen mode Exit fullscreen mode

Now the external call awaits. It yields control while waiting, the event loop stays free to serve everyone else, and the framework does what it was built to do.

Option 2: Push the blocking call to a threadpool

Sometimes you can't swap the library (it's sync-only, or it's bcrypt doing CPU work). FastAPI gives you run_in_threadpool to run blocking code on a separate thread, off the event loop, so it can't freeze it:

from fastapi.concurrency import run_in_threadpool

@app.get("/profile/{user_id}")
async def get_profile(user_id: int):
    user = await db.fetch_user(user_id)
    avatar = await run_in_threadpool(requests.get, f"https://avatars.example.com/{user_id}")
    return {"user": user, "avatar": avatar.json()}
Enter fullscreen mode Exit fullscreen mode

The blocking requests.get now runs on a worker thread; the event loop awaits that and stays free. You keep the sync library and stop freezing the server.

Option 3: The one-word fix. Just use def

This is the part that surprises everyone, and it's genuinely the simplest option. If your endpoint is mostly blocking/sync work anyway, drop the async:

@app.get("/profile/{user_id}")
def get_profile(user_id: int):          # note: def, not async def
    user = db.fetch_user_sync(user_id)
    avatar = requests.get(f"https://avatars.example.com/{user_id}")
    return {"user": user, "avatar": avatar.json()}
Enter fullscreen mode Exit fullscreen mode

Here's the thing I didn't know: FastAPI runs plain def endpoints in a threadpool automatically. When you write def instead of async def, FastAPI sees it's a regular sync function and runs it on a worker thread. A blocking call inside it blocks that thread, not the event loop, and other requests sail on.

The framework was protecting me the whole time. I'd opted out of that protection by writing async def and then blocking inside it.

That's the irony that still stings: async def with a blocking call is worse than plain def.

Writing async felt more advanced, more correct, more "fast", and it was the exact thing that caused the outage.


The Rule I Now Live By

The lesson compressed into one rule, the one I wish someone had tattooed on me before the migration:

async def is a promise that you will not block. Everything inside it must await when it waits. If you can't keep that promise (if there's a blocking call you can't make async), then either run it in a threadpool, or don't use async def at all. Use plain def and let FastAPI handle the threading for you.

Put differently: don't reach for async def because it sounds faster. Reach for it only when your endpoint genuinely does async, await-able I/O.

The moment a synchronous, blocking call (requests, time.sleep, a sync DB driver, bcrypt, a heavy CPU loop, file I/O without async) lands inside an async def with no threadpool around it, you haven't written fast code. You've written a server-wide freeze that only reveals itself under load.

And that's the sting of this whole class of bug: it is invisible in development. One request at a time, a frozen event loop looks identical to a working one, because there's no one else in line to notice they're stuck. You need concurrency to see it, and concurrency is exactly what you don't have on your laptop and absolutely have in production. Which is why it waits until the worst possible moment to show itself.


How to Catch It Before It Catches You

If you take anything operational from my bad day, take these:

  • Audit every async def endpoint for blocking calls. Search for requests., time.sleep, sync database drivers, open() file reads, bcrypt/hashlib heavy work, any sync SDK. Each one inside an async def with no threadpool is a latent server freeze.
  • When in doubt, use def. If your endpoint isn't doing genuinely async, await-able work, plain def is safer, because FastAPI threadpools it for you. async def is not a free upgrade; it's a contract you have to honor.
  • For blocking calls you can't avoid, wrap them in run_in_threadpool (or an equivalent like anyio.to_thread.run_sync). Never let raw blocking code run naked on the event loop.
  • Load-test with concurrency, not one request at a time. This bug is invisible to single-request testing by its very nature. Throw concurrent load at it (even a simple wrk or locust run) and a frozen event loop reveals itself instantly.

I found my villain eventually: one requests.get on one line. I swapped it for httpx with an await, and watched the latency graph drop off a cliff back to where it should've been. The framework had been fast all along. I'd just quietly told it to stop.


The 30-Second Takeaway

  • FastAPI runs on a single-threaded event loop that serves many requests concurrently by having each one await (yield) while it waits on slow I/O.
  • An async def endpoint runs directly on the event loop. A blocking call inside it (one that takes time but doesn't await: requests.get, time.sleep, a sync DB driver, bcrypt, heavy CPU) freezes the entire loop. Every concurrent request hangs, not just that one.
  • It's invisible in development (one request at a time can't reveal a frozen shared loop) and only appears under real concurrency in production. It's the worst-possible-moment bug.
  • The irony: async def + a blocking call is worse than plain def, because FastAPI runs plain def endpoints in a threadpool automatically. Blocking there only ties up a worker thread, not the whole server.
  • Three fixes:
    1. Use a real async library and await it (requests → httpx).
    2. Wrap the blocking call in run_in_threadpool.
    3. Just use def and let FastAPI thread it.
  • The rule: async def is a promise not to block. Honor it with await everywhere, or don't use async def. And load-test with concurrency, because single-request testing will never catch this.

Your Turn

Open your FastAPI app and grep your async def endpoints for requests., time.sleep, and any sync database call. Every hit is a line that works perfectly in testing and freezes your whole server under load: exactly the one that cost me a day.

Have you been bitten by this one? Tell me in the comments 👇

If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.

Top comments (0)