FastAPI is my go-to for Python backends now. But it wasn't always. We started on Flask, and moved for real reasons, not just because FastAPI was newer.
Here's what actually changed, and the numbers that came with it.
Why we left Flask
Flask's async support always felt bolted on afterward. FastAPI has it built in from the start, plus automatic API docs and real type checking through Pydantic.
In our load tests, FastAPI handled roughly 3000 requests per second where Flask managed about 1000 on the same hardware. That gap alone justified the migration.
From one big app to several small ones
We had a single Flask app doing auth, users, tasks, and data, all tangled together. One bug in any part could take down everything, and we couldn't scale just the busy part.
1┌────────────────────────────────────────┐2│ Flask Monolith │3│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │4│ │ Auth │ │ User │ │ Task │ │ Data │ │5│ └──────┘ └──────┘ └──────┘ └──────┘ │6└────────────────────────────────────────┘We split it into separate services behind an API gateway, each with its own database, each deployable on its own.
1┌─────────────┐2│ API Gateway │3└──────┬──────┘4 │5┌──────┴──────┬──────────────┬──────────────┐6▼ ▼ ▼ ▼7┌─────┐ ┌──────┐ ┌──────┐ ┌──────┐8│Auth │ │ User │ │ Task │ │ Data │9└─────┘ └──────┘ └──────┘ └──────┘Going async, properly
The real win in FastAPI only shows up if you actually go async everywhere, not just in the framework.
1# Blocking, holds up the whole event loop2@app.get("/users/{user_id}")3def get_user(user_id: int, db: Session = Depends(get_db)):4 return db.query(User).filter(User.id == user_id).first()5
6# Async, doesn't block anything else7@app.get("/users/{user_id}")8async def get_user(user_id: int, db: AsyncSession = Depends(get_async_db)):9 result = await db.execute(select(User).where(User.id == user_id))10 return result.scalar_one_or_none()Database connections are expensive to open, so we pool them instead of creating new ones per request.
1engine = create_async_engine(2 DATABASE_URL,3 pool_size=20,4 max_overflow=30,5 pool_timeout=30,6 pool_recycle=1800,7)And a lot of reads don't need to hit the database every single time.
1@app.get("/products/{product_id}")2@cache(expire=300)3async def get_product(product_id: int):4 return await fetch_product(product_id)What happened at 10K concurrent users
We load tested with Locust before and after these changes.
| Metric | Before | After |
|---|---|---|
| RPS | 2,500 | 4,200 |
| P50 Latency | 180ms | 95ms |
| P95 Latency | 850ms | 280ms |
| Error Rate | 2.3% | 0.1% |
| -------- | -------- | ------- |
|---|---|---|
| P50 Latency | 180ms | 95ms |
| P95 Latency | 850ms | 280ms |
| Error Rate | 2.3% | 0.1% |
| RPS | 2,500 | 4,200 |
|---|---|---|
| P95 Latency | 850ms | 280ms |
| Error Rate | 2.3% | 0.1% |
| P50 Latency | 180ms | 95ms |
|---|---|---|
| Error Rate | 2.3% | 0.1% |
| P95 Latency | 850ms | 280ms |
|---|
The biggest wins came from switching to an async database driver, connection pooling, Redis caching, and just adding pagination to list endpoints instead of returning everything at once.
Designing for things to fail well
Systems fail. The goal is making sure they fail in a way you can predict and recover from.
We return structured errors instead of raw stack traces, wrap external calls in circuit breakers, expose a real health check endpoint, and fall back gracefully instead of crashing.
1@app.get("/recommendations/{user_id}")2async def get_recommendations(user_id: int):3 try:4 return await ml_service.get_personalized(user_id)5 except ServiceUnavailable:6 return await get_popular_items()7 except Exception:8 return {"recommendations": [], "fallback": True}We also added structured logging with a request ID on every log line, so tracing one request across the system actually works, plus Prometheus metrics for request counts, latency, and status codes.
Where we ended up
40% faster average response time, reliably handling 10K+ concurrent requests, 99.5% deployment success with CI/CD, and zero-downtime rolling deployments.
None of it was exotic. Go async everywhere, pool your connections, cache what you can, and plan for failure instead of hoping it won't happen.
FAQ
Is FastAPI actually faster than Flask?
In our tests, yes, roughly 3000 RPS versus 1000 RPS, mostly because async is native instead of added on top.
When should I split a monolith into microservices?
When a specific part needs to scale, fail, or deploy independently. Splitting earlier than that just adds overhead.
What actually helped us handle 10K concurrent requests?
Going async everywhere, proper connection pooling, and Redis caching on read-heavy endpoints.
Questions about FastAPI or microservices? Connect with me on LinkedIn or GitHub.