The single highest-ROI fix under load: stop opening a new connection for every request. Reuse them.
requests.get(url).
TIME_WAIT exhaustion.
# โ Anti-pattern: a fresh connection (and TLS handshake) every call
import requests
def fetch(url):
return requests.get(url).json() # new pool, new socket, new TLS โ every single time
for url in urls: # 5,000 handshakes/sec โ the network stack is your bottleneck now
fetch(url)
A pool is bounded (e.g. 100 connections). Under load, that bound doubles as a concurrency limit on the downstream โ a feature, not a bug (see limits).
Session
A requests.Session holds a connection pool and is thread-safe for sending. Create it once,
share it across all worker threads.
import requests
from concurrent.futures import ThreadPoolExecutor
from requests.adapters import HTTPAdapter
session = requests.Session()
# size the pool to your worker count so threads don't queue on connections
adapter = HTTPAdapter(pool_connections=50, pool_maxsize=50, max_retries=3)
session.mount("https://", adapter)
session.mount("http://", adapter)
def fetch(url):
return session.get(url, timeout=5).json() # reuses a keep-alive connection
with ThreadPoolExecutor(max_workers=50) as pool:
results = list(pool.map(fetch, urls)) # 50 workers, 50 pooled conns โ no handshake storm
pool_maxsize should match max_workers. If
50 threads share a pool of 10, 40 of them block waiting for a connection โ you accidentally throttled
yourself.
AsyncClient for the whole app
With httpx (or aiohttp), the client owns the pool. Create it once at startup,
reuse everywhere, close on shutdown. Never open a client per request.
import asyncio
import httpx
limits = httpx.Limits(max_connections=100, max_keepalive_connections=50)
async def main(urls):
async with httpx.AsyncClient(limits=limits, timeout=5) as client:
async def fetch(url):
r = await client.get(url)
return r.json()
# one client, one pool, thousands of concurrent requests reusing connections
return await asyncio.gather(*(fetch(u) for u in urls))
asyncio.run(main(urls))
# โ The classic async mistake โ a new client (and pool) per request:
async def fetch(url):
async with httpx.AsyncClient() as client: # builds + tears down a pool EVERY call
return (await client.get(url)).json()
DB connections are even more expensive than HTTP (auth, session setup) and databases cap total connections hard. A pool is mandatory at load.
# Sync (psycopg / SQLAlchemy): a bounded engine pool shared by all threads
from sqlalchemy import create_engine
engine = create_engine(
"postgresql://...",
pool_size=20, # steady-state connections
max_overflow=10, # burst headroom (20 + 10 = 30 hard ceiling)
pool_pre_ping=True, # cheaply verify a conn before use (drops dead ones)
pool_recycle=1800, # recycle conns older than 30 min (avoid stale)
)
# Async (asyncpg): an explicit pool, acquired per query
import asyncpg
pool = await asyncpg.create_pool(dsn, min_size=10, max_size=20)
async with pool.acquire() as conn: # borrow โ use โ auto-return
rows = await conn.fetch("SELECT ...")
max_connections. 10 pods ร pool_size=20 = 200
connections โ make sure Postgres allows it, or use a proxy like PgBouncer.
Connections and sockets cannot be pickled and shared across processes. Each worker process must build its own pool once, in an initializer โ not per task.
from multiprocessing import Pool
import requests
session = None
def init_worker():
global session # runs ONCE per process
session = requests.Session()
def fetch(url):
return session.get(url, timeout=5).status_code # reuse this process's pool
if __name__ == "__main__":
with Pool(processes=4, initializer=init_worker) as pool:
results = pool.map(fetch, urls)
initializer= trick is the pattern for any expensive per-process
resource (DB pool, model, big config): build it once when the worker starts, reuse it for every task.
| Model | Reuse this | Scope |
|---|---|---|
| Threading | requests.Session + sized HTTPAdapter | One, shared across threads |
| Asyncio | httpx.AsyncClient / aiohttp.ClientSession | One per app, created at startup |
| Multiprocessing | Session/pool built in initializer | One per process |
| Any + DB | SQLAlchemy engine / asyncpg pool | Bounded, under server limit |