- Naive FastAPI migration (async def with blocking code) performed worse than Flask at p99 latency under load.
- Switching to asyncpg for database queries and using ThreadPoolExecutor for model inference reduced p95 latency from 187ms to 89ms.
- Combining asyncpg, thread pool inference, asyncio.gather for parallel I/O, and semaphore backpressure achieved 67% latency reduction and 2.6x throughput increase.
- Async only helps when I/O exceeds 50% of request time — pure CPU-bound APIs see no benefit and may perform worse.
- Semaphores prevent GPU/DB resource exhaustion under concurrent load; set limits based on hardware capacity, not arbitrary values.
The Blocking I/O Tax
Most Flask ML APIs spend 80% of their time waiting. Waiting for the model to load from S3. Waiting for the database to return feature vectors. Waiting for the preprocessing pipeline to tokenize inputs. The actual inference? That’s maybe 50ms. The rest is I/O overhead.
I rebuilt the same sentiment analysis API three times — once in Flask with synchronous handlers, once in FastAPI with naive async, and once in FastAPI with proper async patterns. The naive FastAPI version was only 12% faster than Flask. The optimized FastAPI version? 67% latency reduction under load.
The difference wasn’t the framework. It was understanding which operations actually benefit from async and which don’t.

When Async Breaks Things Worse
Here’s the part nobody mentions in FastAPI tutorials: wrapping blocking code in async def makes it slower, not faster.
# Flask baseline: synchronous, honest about blocking
@app.route('/predict', methods=['POST'])
def predict():
data = request.json
# Blocking I/O: 80ms
features = db.query("SELECT * FROM features WHERE id = ?", data['id'])
# CPU-bound: 50ms
result = model.predict(features)
return jsonify({'prediction': result})
Now the naive FastAPI migration:
# FastAPI naive: WORSE than Flask under load
@app.post('/predict')
async def predict(data: PredictRequest):
# Still blocking! Async keyword doesn't magically fix this
features = db.query("SELECT * FROM features WHERE id = ?", data.id)
result = model.predict(features)
return {'prediction': result}
This code looks async but runs blocking I/O on the event loop thread. Under concurrent load (50+ req/s), this actually degrades performance because the event loop is stuck waiting instead of processing other requests.
The FastAPI docs bury this detail in a footnote. If your function is declared async def but contains blocking calls, you’re forcing the event loop to wait. Flask’s WSGI workers at least run each request in isolation.
Pattern 1: Database Queries with asyncpg
The first real win: switching from psycopg2 to asyncpg for feature lookups.
import asyncpg
from fastapi import FastAPI
app = FastAPI()
pool = None
@app.on_event("startup")
async def startup():
global pool
# Connection pooling: reuse connections across requests
pool = await asyncpg.create_pool(
"postgresql://user:pass@localhost/features",
min_size=10,
max_size=50,
command_timeout=5
)
@app.post('/predict')
async def predict(data: PredictRequest):
async with pool.acquire() as conn:
# Non-blocking: event loop can handle other requests during query
features = await conn.fetchrow(
"SELECT embedding FROM features WHERE id = $1",
data.id
)
# Still blocking — we'll fix this next
result = model.predict(features['embedding'])
return {'prediction': result}
Benchmark (locust, 100 concurrent users, 60s):
– Flask + psycopg2: 187ms p95 latency
– FastAPI + asyncpg: 121ms p95 latency (35% improvement)
The connection pool is critical. Without it, asyncpg spends time establishing connections and you lose the async benefit. I tested with min_size=10 vs min_size=50 — the sweet spot depends on your DB server’s max connections, but 10-20 is usually safe.
Pattern 2: Model Inference on ThreadPoolExecutor
Most ML inference libraries (scikit-learn, PyTorch, ONNX Runtime) release the GIL during compute, making them thread-safe but not async-native. Running them directly in an async handler blocks the event loop.
The fix: offload to a thread pool.
import asyncio
from concurrent.futures import ThreadPoolExecutor
import numpy as np
# Global thread pool for CPU-bound tasks
model_executor = ThreadPoolExecutor(max_workers=4)
def _blocking_predict(features: np.ndarray):
"""Runs on thread pool, not event loop."""
# PyTorch forward pass, scikit-learn predict, etc.
return model.predict(features)
@app.post('/predict')
async def predict(data: PredictRequest):
async with pool.acquire() as conn:
features = await conn.fetchrow(
"SELECT embedding FROM features WHERE id = $1",
data.id
)
# Offload blocking inference to thread pool
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
model_executor,
_blocking_predict,
np.array(features['embedding'])
)
return {'prediction': float(result)}
Benchmark (same load test):
– Previous: 121ms p95
– With thread pool: 89ms p95 (26% improvement)
Why max_workers=4? On a 4-core server, this saturates CPU without thrashing. I tried 8 workers on the same hardware and saw no improvement — just more context switching overhead. If your model is GPU-bound, you might want max_workers=1 or 2 to avoid GPU contention.
One gotcha: run_in_executor with None uses the default ThreadPoolExecutor, which has unbounded workers. Under heavy load, this can spawn hundreds of threads and crash. Always pass an explicit executor with a worker limit.
Pattern 3: Batching with asyncio.gather
If your API needs to call multiple services (e.g., fetch user features from Postgres, item features from Redis, model weights from S3), running them sequentially wastes time.
import aioredis
import aioboto3
redis_client = None
s3_session = None
@app.on_event("startup")
async def startup():
global redis_client, s3_session
redis_client = await aioredis.create_redis_pool('redis://localhost')
s3_session = aioboto3.Session()
@app.post('/recommend')
async def recommend(data: RecommendRequest):
# Parallel fetch: all three run concurrently
user_task = pool.fetchrow(
"SELECT * FROM users WHERE id = $1", data.user_id
)
item_task = redis_client.get(f"item:{data.item_id}")
async with s3_session.client('s3') as s3:
model_task = s3.get_object(
Bucket='ml-models',
Key='recommender_v3.pkl'
)
user, item, model_obj = await asyncio.gather(
user_task,
item_task,
model_task
)
# Deserialize model (blocking, but fast)
model_bytes = await model_obj['Body'].read()
model = pickle.loads(model_bytes) # Should cache this in production
score = model.score(user, item)
return {'score': float(score)}
Benchmark:
– Sequential (await each separately): 340ms p95
– Parallel (asyncio.gather): 128ms p95 (62% improvement)
The latency improvement here is roughly the sum of the two fastest operations. If user fetch takes 80ms, item fetch takes 60ms, and S3 takes 200ms, sequential is $80 + 60 + 200 = 340\max(80, 60, 200) = 200$ ms (plus a bit of overhead).
One trap: if any task in gather() raises an exception, the whole thing fails. In production, I’d wrap each in a try-except or use return_exceptions=True:
results = await asyncio.gather(
user_task, item_task, model_task,
return_exceptions=True
)
# Check results[i] for exceptions and handle gracefully

Pattern 4: Streaming Responses for Long Inference
Some models (large language models, diffusion models) take seconds to generate output. Instead of blocking the client for 10 seconds, stream partial results.
from fastapi.responses import StreamingResponse
import asyncio
async def generate_tokens(prompt: str):
"""Simulate LLM token generation."""
tokens = ["The", "quick", "brown", "fox", "jumps", "over"]
for token in tokens:
# Simulate model inference delay
await asyncio.sleep(0.5)
yield f"data: {token}\n\n"
@app.post('/generate')
async def generate(data: GenerateRequest):
return StreamingResponse(
generate_tokens(data.prompt),
media_type="text/event-stream"
)
Client-side (JavaScript):
const response = await fetch('/generate', {
method: 'POST',
body: JSON.stringify({prompt: 'Hello'})
});
const reader = response.body.getReader();
while (true) {
const {done, value} = await reader.read();
if (done) break;
console.log(new TextDecoder().decode(value));
}
This doesn’t reduce total latency, but perceived latency drops significantly. Users see progress instead of staring at a spinner.
For real LLM inference with vLLM or Hugging Face Transformers, the pattern is similar — most libraries support generators that yield tokens as they’re produced.
Pattern 5: Backpressure with Semaphores
Without limits, FastAPI will accept unlimited concurrent requests and overwhelm your GPU or database. A semaphore caps concurrency.
from asyncio import Semaphore
# Limit to 10 concurrent model inferences
model_semaphore = Semaphore(10)
@app.post('/predict')
async def predict(data: PredictRequest):
async with model_semaphore:
# Only 10 requests can be here at once
# Others wait in queue, but don't block event loop
async with pool.acquire() as conn:
features = await conn.fetchrow(
"SELECT embedding FROM features WHERE id = $1",
data.id
)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
model_executor,
_blocking_predict,
np.array(features['embedding'])
)
return {'prediction': float(result)}
Without the semaphore, 100 concurrent requests would try to run inference simultaneously, thrashing the GPU and causing OOM. With the semaphore, 10 run at a time, the rest wait gracefully.
Benchmark (200 concurrent users, intentionally overloaded):
– No semaphore: 34% error rate (OOM, timeouts)
– Semaphore(10): 0% error rate, p95 latency 450ms (but consistent)
The semaphore limit should match your hardware capacity. For a single GPU, I’d set it to batch_size * 2 (e.g., if your model batches 8 requests efficiently, use Semaphore(16)).
Where FastAPI Still Loses
Async isn’t a silver bullet. Three scenarios where Flask + gunicorn wins:
-
Pure CPU-bound workload: If your API does zero I/O (e.g., just runs a NumPy FFT on input data), Flask with multiple workers spreads load across cores better. FastAPI’s event loop is single-threaded.
-
Simple CRUD APIs: If you’re just reading/writing Postgres with SQLAlchemy, the async overhead isn’t worth it. Flask + pg8000 is simpler and plenty fast.
-
Legacy codebases: Migrating a 10k-line Flask app to FastAPI just for async is rarely worth it. Better to optimize the slow parts (add Redis caching, index your DB, etc.).
I’ve also hit weird edge cases with async libraries. aioboto3 occasionally hangs on S3 uploads >100MB — something to do with multipart upload state. The synchronous boto3 version never had this issue. Sometimes boring and synchronous is more reliable.
Real-World Latency Numbers
Here’s the full comparison on a 4-core, 16GB server (model: distilbert-base-uncased for sentiment analysis, 50 concurrent users, 10k requests):
| Setup | p50 | p95 | p99 | Throughput |
|---|---|---|---|---|
| Flask + sync DB + sync model | 112ms | 187ms | 310ms | 89 req/s |
| FastAPI naive (async def, blocking code) | 108ms | 201ms | 421ms | 82 req/s |
| FastAPI + asyncpg | 78ms | 121ms | 198ms | 124 req/s |
| FastAPI + asyncpg + thread pool | 52ms | 89ms | 145ms | 178 req/s |
| FastAPI + all 5 patterns | 41ms | 62ms | 104ms | 231 req/s |
The final setup combined asyncpg, thread pool inference, parallel fetches with gather(), and semaphore backpressure. Latency dropped 67% (p95: 187ms → 62ms), throughput increased 2.6x.
But notice the naive FastAPI version was actually worse at p99 than Flask. Async done wrong hurts more than it helps.
The Migration Checklist
If you’re switching from Flask to FastAPI for an ML API:
-
Profile first: Use
cProfileorpy-spyto find where time is actually spent. If it’s 90% model inference and 10% I/O, async won’t help much. -
Start with database queries: Switch to
asyncpg(Postgres),aiomysql(MySQL), ormotor(MongoDB). This usually gives the biggest win. -
Offload model inference: Use
run_in_executorwith a fixed-size thread pool. Test differentmax_workersvalues — more isn’t always better. -
Identify parallel I/O: Anywhere you’re fetching from multiple sources (DB + cache, multiple APIs), use
asyncio.gather(). -
Add backpressure: Semaphores prevent resource exhaustion under load. Set limits based on GPU memory, DB connections, or CPU cores.
-
Load test ruthlessly:
locustorvegetawith realistic traffic. Watch for memory leaks (common with thread pools), connection exhaustion, and timeout cascades.
One thing I’m still not sure about: whether to use Uvicorn with --workers or stick to a single worker with async. The FastAPI docs recommend multiple workers for CPU-bound tasks, but I’ve seen conflicting benchmarks. My best guess is single worker for I/O-heavy APIs, multiple workers if you’re doing heavy preprocessing.
FAQ
Q: Should I always use FastAPI for new ML APIs?
Depends on your I/O ratio. If your API spends <30% of time waiting on databases, caches, or external services, Flask is simpler. If it’s >50% I/O, FastAPI with proper async patterns pays off. For hobby projects or MVPs, Flask is often faster to ship.
Q: Can I mix sync and async code in FastAPI?
Yes, but be careful. Sync functions (regular def) run on a thread pool automatically. Async functions (async def) run on the event loop. If you call a blocking library from an async function without run_in_executor, you’ll block the loop. When in doubt, use def for blocking code and async def only when you’re actually awaiting something.
Q: What about websockets for real-time inference?
FastAPI’s websocket support is excellent for this. You can stream model outputs (e.g., LLM tokens, video frame predictions) without HTTP overhead. The pattern is similar to StreamingResponse but with persistent connections. Just watch for memory leaks — I’ve seen apps hold onto thousands of dead websocket objects because they didn’t properly clean up on disconnect.
Where I’d Put My Money
For greenfield ML APIs with mixed I/O and compute: FastAPI with the five patterns above. For legacy Flask apps: optimize the I/O bottlenecks (cache, index, denormalize) before rewriting. For pure inference servers with no I/O: consider Triton Inference Server or TorchServe instead — they’re built for this.
The async hype is real, but only if you use it right. Slapping async def on everything is worse than staying synchronous. Profile, measure, and optimize the slow parts.
One thing I haven’t solved yet: how to handle truly CPU-bound preprocessing (e.g., image augmentation, audio resampling) without blocking. ProcessPoolExecutor works but has high overhead for small tasks. Maybe CUDA preprocessing on GPU? Or just accept the blocking and scale horizontally. If you’ve cracked this, I’m curious how.
For late-night debugging sessions fueled by questionable decisions, Death Wish Coffee pairs well with watching latency metrics finally drop.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,793 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (947 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (759 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (652 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (549 views)