FastAPI `async`: A Small Keyword with Huge Impact

Introduction

Modern web applications are often I/O-bound—waiting on databases, APIs, or file systems. Python’s async/await syntax allows us to handle these operations concurrently without blocking, which can lead to major performance gains. But in FastAPI, using async carelessly can have the opposite effect.

Let’s explore how a single keyword—async—can either unlock FastAPI’s full performance potential or become a surprising bottleneck.


Python’s async/await

Python’s async and await syntax lets you define coroutines, which can pause execution without blocking the entire program. This is especially useful when doing I/O-bound work, such as waiting for a network response or file read/write.

With async/await, while one task is waiting, another can run. This allows more efficient use of resources, without spawning threads or processes.


FastAPI Overview

FastAPI is a modern, high-performance Python web framework used for building APIs. It supports both synchronous and asynchronous routes and is designed with performance and developer experience in mind.

FastAPI is asynchronous-first, but that doesn’t mean every route should be async by default—especially when calling blocking code.


Benchmark Setup

  • Machine: MacBook Pro M1
  • Python version: 3.11
  • FastAPI settings: Default
  • Benchmark tool: ApacheBench (ab)
  • Test scenario: 100 concurrent users making 200 total requests
  • Benchmark Results:
    • Requests per second: The server throughput, which is 200 / Total time.
    • Total time: Total time to finish the above 200 requests.

Synchronous vs. Asynchronous Routes

Example Routes

from fastapi import FastAPI

app = FastAPI()

# Synchronous route
@app.get("/sync")
def read_sync():
    return {"message": "Hello World"}

# Asynchronous route
@app.get("/async")
async def read_async():
    return {"message": "Hello World"}

At first glance, using async def seems like the right choice—especially for performance. But let’s test that assumption with an I/O-heavy example.


Simulating I/O Operations

# Synchronous I/O operation
import time

def sync_io_heavy():
    time.sleep(3)
    return {"message": "Hello World"}

Case 1: Synchronous Route with Blocking I/O

@app.get("/")
def read_root():
    sync_io_heavy()
    sync_io_heavy()
    return {"message": "Hello World"}

Benchmark Results:

  • Requests per second: 5.53
  • Total time: 36 seconds

Case 2: Asynchronous Route with Blocking I/O

@app.get("/")
async def read_root():
    sync_io_heavy()
    sync_io_heavy()
    return {"message": "Hello World"}

Benchmark Results:

  • Requests per second: 0.17
  • Total time: 1200 seconds

⚠️ This is nearly 40x slower than the synchronous version.

Why?

Even though the route is marked async, the sync_io_heavy() function blocks the event loop. In async mode, one blocking call stalls the entire server, preventing it from handling other requests.


Fix: Use Asynchronous I/O

import asyncio

async def async_io_heavy():
    await asyncio.sleep(3)
    return {"message": "Hello World"}

@app.get("/")
async def read_root():
    await async_io_heavy()
    await async_io_heavy()
    return {"message": "Hello World"}

Benchmark Results:

  • Requests per second: 11.06
  • Total time: 18 seconds

✅ Nearly 2x faster than the synchronous route!


When You Can’t Make Everything Async

Sometimes, you’re stuck with blocking code (e.g., legacy libraries or synchronous DB drivers). In such cases, you can still use async routes without hurting performance—by isolating the blocking call in a threadpool.

from fastapi.concurrency import run_in_threadpool

@app.get("/")
async def read_root():
    await run_in_threadpool(sync_io_heavy)
    await async_io_heavy()
    return {"message": "Hello World"}

Benchmark Results:

  • Requests per second: 8.29
  • Total time: 24 seconds

✅ About 50% faster than using a fully synchronous route.


Conclusion

In FastAPI, async is powerful—but not magic. Simply slapping async def on a route doesn’t guarantee better performance, especially if it calls blocking code.

Key Takeaways:

  • ✅ Use async only when all I/O within the route is non-blocking.
  • ❌ Avoid using blocking I/O (like time.sleep() or standard DB drivers) inside async def routes.
  • ⚙️ Use run_in_threadpool() to safely call blocking code in an async context.
  • 🚀 Proper use of async can double or even triple your performance.

A single async keyword can make or break your FastAPI application’s throughput—use it wisely.


Reference

Published-date