A practical guide to calculating the right connection pool size for your FastAPI deployment.
| Parameter | How to determine |
|---|---|
workers |
Gunicorn/Uvicorn worker count |
threads |
Threads per worker (for gthread worker class) |
async_pool |
Whether using async endpoints |
max_concurrent_requests |
Expected peak concurrent requests |
db_max_connections |
Database server connection limit |
external_services |
Number of external APIs/DBs you call |
Each sync request holds a connection for the entire request duration.
pool_size_per_worker = min(
anyio_thread_tokens, # default 40 — eliminates connection bottleneck
max_concurrent_requests, # your expected concurrency
db_max_connections / workers # don't exceed DB limit
)
CI-verified (run 29916760540):
Conservative (memory-constrained):
pool_size_per_worker = 40 # matches anyio default, eliminates bottleneck
Aggressive (throughput-critical):
pool_size_per_worker = 100 # +0.6% over pool=40, ~6.5 MB more per 2 workers
Connections are shared across coroutines. Multiple coroutines can use the same connection (not simultaneously, but sequentially during awaits).
pool_size_per_worker = concurrent_requests / 2
Async pools can be smaller because:
awaittotal = pool_size_per_worker × workers
Constraint:
total ≤ external_service_max_connections
workers: 2
endpoint_type: async
concurrent_requests: 50
db_max_connections: 100
pool_size_per_worker = 50 / 2 = 25
total = 25 × 2 = 50 ≤ 100 ✓
Recommendation: max_connections=25, max_keepalive=10
workers: 4
endpoint_type: sync
concurrent_requests: 100
db_max_connections: 200
pool_size_per_worker = min(40, 100, 200/4) = 40
total = 40 × 4 = 160 ≤ 200 ✓
Recommendation: max_connections=40, max_keepalive=15
workers: 8
endpoint_type: mixed (60% async, 40% sync)
concurrent_requests: 500
db_max_connections: 300
# Async endpoints
async_pool = 300 / 2 = 150 per worker → too high
async_pool = min(150, 500/8) = 62 per worker
# Sync endpoints
sync_pool = min(40, 500×0.4/8) = min(40, 25) = 25 per worker
# DB constraint
total_sync = 25 × 8 = 200
total_async = 62 × 8 = 496 → exceeds DB limit!
Fix: Reduce async pool to 300/8 = 37 per worker.
Recommendation: max_connections=37, max_keepalive=15
workers: 4
services:
- PostgreSQL: max_connections=100
- Redis: max_connections=512
- External API: no limit (but rate-limited)
# PostgreSQL
pg_pool = 100 / 4 = 25 per worker
# Redis (connection is lightweight)
redis_pool = 50 per worker
# External API (rate-limited at 1000 RPS)
api_pool = 20 per worker
Each service gets its own pool with its own limits.
CI-verified: pool=40 eliminates the connection bottleneck. pool=100 achieves +0.6% at +6.5 MB/2 workers.
| Workers | Sync pool/worker (conservative) | Sync pool/worker (aggressive) | Total sync (conservative) | Total sync (aggressive) | Memory (conservative) | Memory (aggressive) |
|---|---|---|---|---|---|---|
| 1 | 40 | 100 | 40 | 100 | 2.2 MB | 5.6 MB |
| 2 | 40 | 100 | 80 | 200 | 4.5 MB | 11.2 MB |
| 4 | 40 | 100 | 160 | 400 | 9 MB | 22.4 MB |
| 8 | 40 | 100 | 320 | 800 | 18 MB | 44.8 MB |
| 16 | 40 | 100 | 640 | 1600 | 36 MB | 89.6 MB |
As workers increase, total connections scale linearly. Stay within external service limits (e.g., PostgreSQL max_connections=100).
httpx.PoolTimeout exceptionsconnection pool exhausted errors in logs# Monitor httpx pool
import httpx
client = httpx.Client()
# After load test:
print(f"Pool connections: {client._pool._connections}")
print(f"Pool available: {client._pool._available}")
sync_pool = min(40, concurrent_requests, db_max/workers) + headroom
async_pool = concurrent_requests / 2
total = pool × workers ≤ db_max_connections
headroom = 2-5 (for connection state transitions)
When in doubt, start small and scale up based on measured performance.