FastAPI Performance Showdown: Sync vs Async — Which is Better?

Dhruv Patel
5 min readSep 12, 2024

--

FastAPI Logo

FastAPI has become one of the most popular frameworks for building APIs in Python due to its speed and ease of use. But when you’re building high-performance applications, there’s an important question you need to answer: Should you use synchronous (sync) or asynchronous (async) code execution?

In this article, we’ll benchmark both sync and async FastAPI implementations using real-world performance tests and dive deep into the numbers to help you decide when to use each approach.

Sync vs Async in FastAPI: What’s the Difference?

  • Synchronous Code (Sync): In synchronous execution, tasks are processed one at a time. Each request waits for the previous one to finish, which can lead to bottlenecks if there are multiple users or slow I/O operations like database queries or file uploads.
  • Asynchronous Code (Async): Asynchronous execution allows multiple requests to be processed concurrently. Instead of waiting for I/O operations (like database calls) to finish, the application can continue handling other requests, making it more efficient in high-concurrency environments.

But what’s the real difference in performance between the two? Let’s find out.

The Setup: Benchmarking Sync vs Async in FastAPI

To compare sync and async implementations, I created two versions of a FastAPI application:

  1. Sync Version: Using traditional blocking database queries with psycopg2.
  2. Async Version: Using non-blocking async queries with asyncpg.

Both versions perform a simple task: querying a user from a PostgreSQL database. The database contains minimal data to isolate the effects of the sync/async mechanisms.

Tech Stack:

  • FastAPI for the API framework.
  • SQLAlchemy for the ORM.
  • psycopg2 or psycopg2-binary (Sync) and asyncpg (Async) for database connections.
  • PostgreSQL as the database.

To test the performance, I used Apache Benchmark (ab) to simulate 1000 requests with 100 concurrent connections.

Code for Sync Version 💻

In the sync version, we use the psycopg2 driver with SQLAlchemy, which performs blocking queries. The table is created using the synchronous SQLAlchemy engine.

Sync: main.py

from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from .database import get_db, User

app = FastAPI()

@app.get("/users/{user_id}")
def get_user(user_id: int, db: Session = Depends(get_db)):
# Synchronous and blocking
user = db.query(User).filter(User.c.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"id": user.id, "name": user.name, "email": user.email}

Sync: database.py

from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String
from sqlalchemy.orm import sessionmaker

DATABASE_URL = "postgresql://user:password@localhost/db_name"

# Create a synchronous SQLAlchemy engine
engine = create_engine(DATABASE_URL, echo=True)

# Create a session maker for synchronous queries
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)

# Define metadata
metadata = MetaData()

# Define User table
User = Table(
"users", metadata,
Column("id", Integer, primary_key=True),
Column("name", String),
Column("email", String),
)

# Create the table in the database
metadata.create_all(engine)

# Dependency to get a synchronous DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

Code for Async Version 💻

In the async version, we use the asyncpg driver with SQLAlchemy, which performs non-blocking queries. However, the table creation still happens synchronously because SQLAlchemy’s metadata.create_all() is not async-compatible.

Async: main.py

from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from .database import get_async_db, User, initialize_database


@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Initialize the database
await initialize_database()
yield


app = FastAPI(lifespan=lifespan)


@app.get("/users/{user_id}")
async def get_user(user_id: int, db: AsyncSession = Depends(get_async_db)):
result = await db.execute(select(User).where(User.c.id == user_id))
user = result.fetchone()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"id": user.id, "name": user.name, "email": user.email}

Async: database.py

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import MetaData, Table, Column, Integer, String

DATABASE_URL = "postgresql+asyncpg://user:password@localhost/db_name"

# Async engine for async queries
engine = create_async_engine(
DATABASE_URL,
echo=True,
pool_size=10,
max_overflow=20,
)

# Async session for async queries
AsyncSessionLocal = sessionmaker(
bind=engine, class_=AsyncSession, expire_on_commit=False
)

# Define metadata
metadata = MetaData()

# Define User Table
users = Table(
"users",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String),
Column("email", String),
)


# Create all tables
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(metadata.create_all)


# Dependency to get async DB session
async def get_async_db():
async with AsyncSessionLocal() as session:
yield session

# Startup: Initialize the database
async def initialize_database():
await init_db()

In this version, requests are handled asynchronously, allowing multiple requests to be processed concurrently while waiting for I/O.

Benchmark commands:

  1. Sync version:- ab -n 1000 -c http://127.0.0.1:8000/users/1
  2. Async version:- ab -n 1000 -c http://127.0.0.1:8001/users/1

Benchmark Results: Sync vs Async

Here’s the breakdown of performance metrics from the benchmark:

Benchmark Results: Sync vs Async (Airtable)

Performance Breakdown

1. Requests per second:

  • The Async Version handles 50.68 requests per second, while the Sync Version only manages 36.89 requests per second.
  • Async is handling 37% more requests within the same time frame, making it the clear winner in terms of concurrency.

2. Response time per request (mean):

  • The mean response time for the async version is 27% lower (1973 ms vs. 2710 ms), indicating that async handles requests more efficiently under high load.

3. Longest request time:

  • Both versions show similar longest request times (~4000 ms), but the async version performs more consistently, as shown by the lower spread in the response times.

Graphical Comparison

Graphical Comparison

Here is the graph comparing the Sync and Async versions across various percentiles, including the mean and longest request times:

  • The solid lines show the response times at different percentiles.
  • The dashed lines represent the mean response times for both sync (2710.648 ms) and async (1973.057 ms).
  • The dotted lines highlight the longest request times for sync (4167 ms) and async (3851 ms).

When Should You Use Sync vs Async in FastAPI?

Use Async When:

  • Your app needs to handle high traffic and many concurrent users.
  • The application is I/O-bound, with lots of database queries or API calls.
  • You need to minimize response time for a large number of requests.

Use Sync When:

  • The app has minimal concurrency or performs primarily CPU-bound tasks.
  • You want to keep the codebase simpler, avoiding the complexity of async handling.
  • Your app isn’t expected to scale up to handle hundreds or thousands of requests at once.

Optimizing Async Performance

While async is faster in these tests, there are ways to optimize it further:

  1. Connection Pooling: Use connection pooling to reuse database connections and avoid creating a new one for every request.
  2. Use Asynchronous Libraries: Ensure all I/O-bound tasks (e.g., file reads/writes, external API calls) are handled asynchronously for maximum performance.
  3. Test Higher Concurrency: Run load tests with higher concurrency (e.g., 500+ users) to fully leverage the benefits of async.
engine = create_async_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20
)

Let’s Experiment!

Have you tried implementing Sync or Async in FastAPI? What were your results? Share your experience or run the same benchmarks on your applications. I’d love to see how async handles under various loads and use cases!

Conclusion:

If your app handles many concurrent users and relies heavily on I/O-bound tasks, Async FastAPI offers better performance, scalability, and responsiveness. However, for simpler use

--

--

Dhruv Patel

Writing about productivity systems and life optimization strategies. Software engineer sharing insights on clean code, architecture, and best practices.