Your FastAPI App Is Async. So Why Is It Still Slow? ๐
Understanding the difference between writing async code and building an actually concurrent Python backend.
You built your API with FastAPI.
You used async def everywhere.
You even added await to your database calls.
So why does one slow request still seem to affect everything else?
Here's something that surprises a lot of Python developers:
Using async doesn't automatically make your application non-blocking.
And in some cases, a perfectly valid asynchronous endpoint can make your application perform worse than a regular synchronous one.
Let's explore why.
1. The async def Illusion
Consider this FastAPI endpoint:
from fastapi import FastAPI
import time
app = FastAPI()
@app.get("/slow")
async def slow_endpoint():
time.sleep(5)
return {"message": "Done"}
At first glance, everything looks fine.
The endpoint uses async def, just like many FastAPI examples.
But there's a problem:
time.sleep(5)
This call blocks the thread running the event loop.
While it executes, the event loop cannot make progress on other coroutines running on that same thread.
If multiple requests arrive, they can end up waiting for each other.
The endpoint is declared asynchronous, but its implementation blocks asynchronous execution.
The fix
Replace the blocking call with an asynchronous one:
import asyncio
from fastapi import FastAPI
app = FastAPI()
@app.get("/slow")
async def slow_endpoint():
await asyncio.sleep(5)
return {"message": "Done"}
Now something important changes.
When Python reaches:
await asyncio.sleep(5)
The coroutine suspends and gives control back to the event loop.
Other requests can make progress while the first one waits.
This is the foundation of asynchronous programming.
2. Concurrency Is Not Parallelism
This distinction matters.
Concurrency means multiple tasks can make progress during overlapping periods.
Parallelism means multiple tasks actually execute at the same time, typically on different CPU cores.
AsyncIO is primarily about cooperative concurrency.
Imagine three API requests, each waiting two seconds for an external service.
With blocking operations, a single-threaded execution model might look like this:
Request A: [----2s----]
Request B: [----2s----]
Request C: [----2s----]
Total: ~6 seconds
With asynchronous waiting:
Request A: [----2s----]
Request B: [----2s----]
Request C: [----2s----]
Total: ~2 seconds
These are simplified timelines, not benchmark results. Real application performance also depends on worker configuration, connection pools, infrastructure, and external services.
The key idea is that asynchronous tasks can overlap their waiting time.
But what happens when the work is CPU-intensive?
That's where things get interesting.
3. AsyncIO Won't Magically Speed Up CPU-Heavy Code
Imagine your API needs to perform an expensive calculation.
def expensive_calculation():
total = 0
for i in range(30_000_000):
total += i * i
return total
Now put it inside an async endpoint:
@app.get("/calculate")
async def calculate():
result = expensive_calculation()
return {"result": result}
This is still blocking.
Why?
Because expensive_calculation() is regular Python code that continuously uses CPU time.
There is no asynchronous waiting point where the coroutine can yield control.
Adding async to the endpoint doesn't transform CPU-bound work into non-blocking work.
A better approach
For CPU-intensive operations, you may need:
- A separate process
- A process pool
- A background job system
- A dedicated worker service
For example, Python's ProcessPoolExecutor can execute work in separate processes.
import asyncio
from concurrent.futures import ProcessPoolExecutor
from fastapi import FastAPI
app = FastAPI()
pool = ProcessPoolExecutor(max_workers=2)
def expensive_calculation():
return sum(i * i for i in range(30_000_000))
@app.get("/calculate")
async def calculate():
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
pool,
expensive_calculation,
)
return {"result": result}
The CPU-heavy function runs outside the event-loop process, allowing the event loop to continue serving other requests.
This is a simplified example. In a real deployment, the process pool should be managed as part of the application's lifecycle, and worker counts should be chosen based on available resources.
For expensive or long-running jobs, returning a task ID and processing work asynchronously in a dedicated worker is often a better API design.
4. The Hidden Problem: Synchronous HTTP Clients
Here's another common mistake.
import requests
@app.get("/users")
async def get_users():
response = requests.get(
"https://jsonplaceholder.typicode.com/users",
timeout=10,
)
return response.json()
The requests library performs synchronous HTTP operations.
So even though the endpoint is asynchronous, the outgoing request blocks the event-loop thread until it finishes.
A better choice for async endpoints is an asynchronous HTTP client.
For example, HTTPX:
import httpx
@app.get("/users")
async def get_users():
async with httpx.AsyncClient(
timeout=10.0
) as client:
response = await client.get(
"https://jsonplaceholder.typicode.com/users"
)
response.raise_for_status()
return response.json()
Now the endpoint can yield control while waiting for the external API.
For production applications, it's usually better to reuse an AsyncClient rather than create a new one for every request.
Reusing the client allows connection pooling and reduces unnecessary connection overhead.
5. What About Database Queries?
The same principle applies to databases.
An asynchronous endpoint doesn't automatically make database access asynchronous.
For example:
@app.get("/users/{user_id}")
async def get_user(user_id: int):
user = sync_db_query(user_id)
return user
If sync_db_query() blocks, your event loop is blocked.
With SQLAlchemy's asynchronous API, database operations can look like this:
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
async def get_user(
session: AsyncSession,
user_id: int,
):
result = await session.execute(
select(User).where(User.id == user_id)
)
return result.scalar_one_or_none()
This example assumes that User is a defined SQLAlchemy model and the session uses an asynchronous database driver.
However, there's another important detail:
Async database access doesn't make slow SQL queries fast.
If a query takes three seconds because of missing indexes, inefficient joins, or a poor execution plan, adding await won't fix that.
Async improves how your application handles waiting.
Database optimization improves how quickly that waiting ends.
Both matter.
6. More Concurrency Can Actually Make Things Worse
Suppose your API suddenly receives 1,000 requests.
Each request needs a database connection.
Your PostgreSQL connection pool supports 20 simultaneous connections.
What happens?
The application cannot magically execute 1,000 database queries concurrently using only 20 available connections.
Some requests must wait for a connection to become available.
And increasing the pool size isn't always the right answer.
Too many connections can overload the database and reduce overall throughput.
This is where concepts like backpressure, rate limiting, bounded concurrency, and connection pooling become important.
For example, you can limit concurrent operations using an asyncio.Semaphore.
import asyncio
semaphore = asyncio.Semaphore(10)
async def fetch_external_data(client, url):
async with semaphore:
response = await client.get(url)
response.raise_for_status()
return response.json()
This example limits calls using that semaphore to ten concurrent operations within the process.
It doesn't create a global limit across multiple application workers or servers.
That's an important difference in distributed deployments.
7. How to Know If Your API Is Actually Fast
One of the biggest mistakes in backend development is optimizing code without measuring anything.
An endpoint taking 100 milliseconds doesn't tell you much by itself.
You should also consider:
Latency
How long does an individual request take?
Throughput
How many requests can your application handle per second?
P95 and P99 latency
How long do the slowest 5% and 1% of requests take?
Error rate
Does your application start returning errors under load?
Resource utilization
Are you constrained by CPU, memory, database connections, or an external API?
Tools such as Locust, k6, and Prometheus can help you investigate these questions.
The important part is testing under realistic workloads.
A backend that responds quickly to one request may behave completely differently when hundreds of users arrive simultaneously.
8. A Practical Rule for FastAPI Developers
Here's a mental model worth remembering:
| Workload | Common approach |
|---|---|
| Async HTTP requests | httpx.AsyncClient |
| Async database operations | Async driver + SQLAlchemy AsyncSession |
| Waiting for timers | asyncio.sleep() |
| Blocking I/O library | Sync endpoint or thread pool |
| CPU-intensive calculation | Process pool or dedicated worker |
| Long-running background job | Job queue + worker |
| High-volume external API calls | Connection pooling + bounded concurrency |
These are guidelines, not universal rules.
For example, FastAPI can execute regular def path operations in a thread pool, which is useful when integrating synchronous libraries.
But thread pools have limits, too.
The goal isn't to use async everywhere.
The goal is to choose the right execution model for your workload.
Final Thoughts
AsyncIO is powerful, but it's easy to misunderstand.
A Python application doesn't become scalable simply because its functions contain async and await.
The real performance improvements come from understanding:
- What blocks the event loop
- Where your application spends time waiting
- Which operations are CPU-bound
- How database and HTTP connection pools behave
- When concurrency needs to be limited
- How your application performs under realistic load
The best backend architecture isn't the one with the most asynchronous code.
It's the one that understands where the bottlenecks are.
And sometimes, replacing a single blocking function can make a bigger difference than rewriting an entire service.
๐ฌ Let's Discuss
What's the most unexpected performance bottleneck you've encountered in a Python backend?
Was it a blocking library, a database query, an external API, or something completely different?
I'd love to hear your experience in the comments.
Interested in Python backend development, FastAPI, and building scalable applications?
- GitHub: https://github.com/coffee1337
- Portfolio: https://coffee1337.github.io/
Thanks for reading! ๐
Top comments (0)