DEV Community

Egor
Egor

Posted on

Your FastAPI App Is Async. So Why Is It Still Slow?

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"}
Enter fullscreen mode Exit fullscreen mode

At first glance, everything looks fine.

The endpoint uses async def, just like many FastAPI examples.

But there's a problem:

time.sleep(5)
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

Now something important changes.

When Python reaches:

await asyncio.sleep(5)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

With asynchronous waiting:

Request A: [----2s----]
Request B: [----2s----]
Request C: [----2s----]

Total: ~2 seconds
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now put it inside an async endpoint:

@app.get("/calculate")
async def calculate():
    result = expensive_calculation()
    return {"result": result}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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?

Thanks for reading! ๐Ÿš€

Top comments (0)