Async vs Sync in FastAPI
Last week, I ran into something simple that I realized I didn’t understand well enough to explain.
I’ve been working almost exclusively in FastAPI for over a year. I knew it was “async,” I knew it was “fast,” and I knew roughly how to use it. What I didn’t fully internalize was what FastAPI actually does on your behalf — and what it absolutely does not.
That gap showed up in a pre-release environment.
A seemingly harmless function blocked the event loop. Kubernetes did what Kubernetes does. Liveness probes failed. Pods restarted. The service looked flaky for reasons that weren’t obvious at first glance.
This post is the distilled lesson from that.
One Rule to Remember
If you take nothing else away from this, take this:
If you use
async def, you are promising not to block the event loop.
FastAPI trusts you.
Kubernetes does not.
What FastAPI Actually Does
FastAPI supports both async and sync path operations. The distinction matters more than most people realize.
async defroutes run directly on the event loopdefroutes are run in a thread pool managed by FastAPI
That’s it. No magic. No inference. No safety net.
When you write async def, FastAPI assumes you know what you’re doing and gets out of the way.
When you write def, FastAPI assumes your code might block and isolates it for you.
Most people don’t realize that second part exists.
This is easier to understand visually.
Incoming Request
|
v
+------------------+
| FastAPI Router |
+------------------+
|
|----------------------------------|
| |
v v
async def route def route
(event loop) (thread pool)
| |
blocking call? blocking call?
| |
💥 event loop freezes ✅ only this thread waits
|
requests stall
health checks fail
pod restarts
The Easy Way to Break Everything
Here’s the mistake:
@app.get("/example_route")
async def example():
time.sleep(5)
return "ok"
This looks innocent. It isn’t.
That sleep blocks the event loop. While it’s blocked:
- No other requests are processed
- Health checks time out
- Kubernetes kills the pod
- The cycle repeats
This isn’t a FastAPI bug. It’s you breaking the async contract.
The Wrong Instinct
When something breaks under load, the instinct is to add more controls.
A semaphore here.
A thread pool there.
Maybe make everything async “just to be safe.”
This is usually cargo-cult engineering.
Async doesn’t make blocking libraries non-blocking.
Thread pools don’t fix bad abstractions.
And semaphores don’t improve performance — they just limit the blast radius.
If you didn’t deliberately choose async libraries end-to-end, you probably shouldn’t be using async def in the first place.
FastAPI already gives you a safer default. Most people just don’t realize it.
The Practical Rulebook
I’ve settled on a simple heuristic:
- Unsure? → use
def - Calling blocking I/O? → use
def - Truly async dependencies, intentionally chosen? →
async def
This isn’t about performance micro-optimizations. It’s about correctness.
Async isn’t faster by default. It’s just easier to misuse.
The Takeaway
FastAPI is doing more for you than you think — if you let it.
Before adding “advanced” concurrency patterns, understand what your framework already provides. Most production issues I see aren’t caused by missing abstractions, but by misunderstanding the ones already in place.
Async is a promise.
If you don’t keep it, the system will eventually collect.