- The ThreadPoolExecutor pattern prevents blocking the event loop during synchronous model inference, which is the single biggest latency fix.
- Model warmup during startup eliminates 10-50x first-request latency spikes caused by lazy initialization in sklearn and PyTorch.
- Pydantic validation with Field constraints is faster than hand-rolled checks and generates OpenAPI docs automatically.
- Explicit int() and float() casting on numpy outputs prevents JSON serialization failures that only appear in production.
- Health endpoints should verify model loading without running inference—save deep health checks for less frequent monitoring.
The 50ms Threshold That Changes Everything
Most “deploy your ML model” tutorials get you to a working endpoint. They don’t get you to a fast one. I had a scikit-learn classifier that took 800ms per request when I wrapped it naively in FastAPI. After five specific changes, it dropped to 47ms. Same model, same hardware.
The difference wasn’t clever optimization tricks. It was understanding what FastAPI actually does under the hood and where model inference fits into that picture.
Here’s the final working code, then I’ll break down why each piece matters:
# main.py - the production version
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from contextlib import asynccontextmanager
import numpy as np
import joblib
import asyncio
from concurrent.futures import ThreadPoolExecutor
import time
# Global model and executor
model = None
executor = ThreadPoolExecutor(max_workers=4)
@asynccontextmanager
async def lifespan(app: FastAPI):
global model
print("Loading model...")
model = joblib.load("classifier.joblib")
# Warm up - this matters more than you'd think
dummy = np.zeros((1, 20))
model.predict(dummy)
print(f"Model loaded, classes: {model.classes_}")
yield
executor.shutdown(wait=False)
app = FastAPI(lifespan=lifespan)
class PredictionRequest(BaseModel):
features: list[float] = Field(..., min_length=20, max_length=20)
class PredictionResponse(BaseModel):
prediction: int
confidence: float
latency_ms: float
def _sync_predict(features: np.ndarray):
"""The actual blocking prediction call."""
proba = model.predict_proba(features)
pred = int(np.argmax(proba))
conf = float(proba[0][pred])
return pred, conf
@app.post("/predict", response_model=PredictionResponse)
async def predict(request: PredictionRequest):
start = time.perf_counter()
features = np.array(request.features).reshape(1, -1)
loop = asyncio.get_event_loop()
pred, conf = await loop.run_in_executor(executor, _sync_predict, features)
latency = (time.perf_counter() - start) * 1000
return PredictionResponse(prediction=pred, confidence=conf, latency_ms=latency)
@app.get("/health")
async def health():
return {"status": "healthy", "model_loaded": model is not None}
Run this with uvicorn main:app --workers 4 and you’ve got a production-ready endpoint. But why does this specific structure work so much better than the naive version?

Step 1: Lifespan Events Over @app.on_event
The @app.on_event("startup") decorator still works in FastAPI 0.109+, but it’s deprecated and will eventually disappear. The lifespan context manager is the replacement, and it’s actually better for model serving.
Why? Because cleanup becomes explicit. When your server shuts down, any model resources (GPU memory, file handles, whatever) get released in the yield block. With the old decorator approach, cleanup handlers sometimes didn’t fire during ungraceful shutdowns.
# The old way - still works but deprecated
@app.on_event("startup")
async def load_model():
global model
model = joblib.load("classifier.joblib")
# The new way - explicit cleanup
@asynccontextmanager
async def lifespan(app: FastAPI):
global model
model = joblib.load("classifier.joblib")
yield # Server runs here
del model # Explicit cleanup
The performance impact of this choice? Zero. But the operational impact is significant when you’re deploying behind Kubernetes and pods get terminated with SIGTERM. Your cleanup code actually runs.
Step 2: Model Warmup Is Not Optional
This surprised me. The first prediction after loading a scikit-learn model can be 10-50x slower than subsequent predictions. For neural networks with JIT compilation (PyTorch 2.0’s torch.compile, TensorFlow’s XLA), the difference is even more dramatic.
The warmup call in the lifespan handler isn’t just defensive coding:
# Warmup - triggers lazy initialization
dummy = np.zeros((1, 20))
model.predict(dummy) # This first call is slow
On my test model, first prediction without warmup: 340ms. After warmup: 12ms. Same model, same input shape. The scikit-learn internals do lazy allocation of prediction buffers, and some estimators have additional first-call overhead.
For PyTorch models, the warmup is even more critical:
# PyTorch warmup - run multiple times for stable latency
model.eval()
with torch.no_grad():
dummy = torch.randn(1, 3, 224, 224).to(device)
for _ in range(3): # Multiple warmup passes
_ = model(dummy)
torch.cuda.synchronize() # Wait for GPU ops to complete
Step 3: ThreadPoolExecutor for Blocking Inference
This is where most tutorials fail you. FastAPI is async, but scikit-learn, PyTorch CPU inference, and most ML libraries are synchronous and blocking. If you call model.predict() directly in an async endpoint, you block the entire event loop.
What does that mean practically? Your server can’t handle new connections while a prediction is running. With 4 workers and 100ms inference time, your theoretical max throughput is 40 requests/second. Actually achievable? Maybe 15 RPS because the event loop is blocked.
The fix is run_in_executor:
async def predict(request: PredictionRequest):
loop = asyncio.get_event_loop()
# This runs _sync_predict in a thread, doesn't block event loop
result = await loop.run_in_executor(executor, _sync_predict, features)
return result
With this pattern, the event loop stays responsive. New connections get accepted. Health checks work. The blocking prediction runs in a separate thread.
How many workers in the ThreadPoolExecutor? I use max_workers=4 as a starting point, but the optimal number depends on whether you’re CPU-bound or I/O-bound. For CPU-heavy inference, more threads than CPU cores won’t help and might hurt due to context switching.
Why Not Just Use Sync Endpoints?
FastAPI supports both async and sync endpoint definitions. If you write def predict() instead of async def predict(), FastAPI automatically runs it in a thread pool. So why bother with explicit executors?
Control.
The default thread pool is shared across all sync endpoints. If you have a /predict endpoint that takes 200ms and a /health endpoint that should return instantly, they’re competing for the same threads. With explicit executors, you can prioritize:
prediction_executor = ThreadPoolExecutor(max_workers=4)
background_executor = ThreadPoolExecutor(max_workers=2)
# Predictions get dedicated threads
@app.post("/predict")
async def predict(request: PredictionRequest):
return await loop.run_in_executor(prediction_executor, _predict, request)
# Health checks never wait for predictions
@app.get("/health")
async def health():
return {"status": "ok"}
Step 4: Pydantic Validation Is Faster Than You’d Check Yourself
I’ve seen this pattern too many times:
# Don't do this
@app.post("/predict")
async def predict(request: dict): # Raw dict
if "features" not in request:
raise HTTPException(400, "Missing features")
if not isinstance(request["features"], list):
raise HTTPException(400, "Features must be a list")
if len(request["features"]) != 20:
raise HTTPException(400, "Features must have 20 elements")
# ... more validation
Pydantic does this faster, with better error messages, and it’s already running anyway (FastAPI uses Pydantic internally). Let it work:
class PredictionRequest(BaseModel):
features: list[float] = Field(..., min_length=20, max_length=20)
The Field(...) syntax with min_length and max_length validates array length. The type annotation list[float] validates element types. Pydantic V2 (shipped with FastAPI 0.100+) is compiled Rust under the hood—it’s genuinely faster than hand-rolled Python validation.
One gotcha: Pydantic V2 changed some validation behaviors. If you’re on an older tutorial using @validator, that’s now @field_validator with different semantics. Check your FastAPI version with python -c "import fastapi; print(fastapi.__version__)".

Step 5: Response Model Forces Serialization Discipline
The response_model=PredictionResponse parameter isn’t just documentation. It does three things:
- Filters extra fields — If your internal function returns debug info you don’t want exposed, the response model strips it
- Validates output types — Catches bugs where you accidentally return a numpy int64 instead of Python int
- Generates OpenAPI schema — Your
/docspage shows the exact response structure
That numpy int64 issue is subtle. This fails:
>>> import numpy as np
>>> import json
>>> json.dumps({"prediction": np.int64(1)})
TypeError: Object of type int64 is not JSON serializable
The fix is explicit casting:
pred = int(np.argmax(proba)) # int(), not just np.argmax()
conf = float(proba[0][pred]) # float(), not just array indexing
Pydantic will coerce compatible types, but relying on implicit coercion leads to weird edge cases. Be explicit.
The Latency Breakdown
Where does time actually go? On my M1 MacBook (Python 3.11, FastAPI 0.109, uvicorn 0.27), here’s a breakdown for a RandomForest classifier with 100 trees and 20 features:
| Stage | Time (ms) |
|---|---|
| HTTP parsing + Pydantic validation | 0.3 |
| NumPy array creation | 0.1 |
| Executor dispatch overhead | 0.5 |
| Model inference (100 trees) | 8.2 |
| Response serialization | 0.2 |
| Total | ~9.3 |
For the naive version (no executor, no warmup, validation in code), the same request took 45-800ms depending on whether it was the first request and how much other traffic was hitting the server.
The 800ms worst case came from: first-request warmup (300ms) + event loop blocking causing connection queue buildup (variable, up to 500ms under load).
Batch Prediction: When It Helps, When It Doesn’t
Batch inference is not automatically faster. For CPU-bound sklearn models, processing 10 samples often takes 10x the time of 1 sample. The parallelism is within the model (e.g., across trees in RandomForest), not across samples.
For GPU inference, batching is essential. The GPU kernel launch overhead is fixed regardless of batch size, so you want to amortize it:
# PyTorch batch inference - GPU
async def predict_batch(requests: list[PredictionRequest]):
features = np.array([r.features for r in requests])
tensor = torch.from_numpy(features).to(device)
with torch.no_grad():
logits = model(tensor) # One GPU kernel for N samples
return logits.cpu().numpy()
The optimal batch size depends on your GPU memory and model size. For a ResNet-50 on an RTX 3080 (10GB), I’ve found batch sizes of 32-64 maximize throughput. Above that, memory becomes the bottleneck.
But wait—how do you batch requests that arrive individually? That’s a whole topic unto itself (request coalescing, dynamic batching), and honestly I haven’t nailed it yet. Libraries like Triton Inference Server handle this well, but adding Triton to your stack is a significant complexity jump. For most projects, single-request inference with proper async handling gets you 80% of the way there. I covered the Triton vs TorchServe tradeoffs in I/O Bottleneck Cost Me $800/Month: Triton vs TorchServe if you’re curious about that rabbit hole.
Error Handling That Doesn’t Leak
Model inference can fail in surprising ways. Out-of-range features, NaN inputs, shape mismatches. Your error responses shouldn’t expose internal details:
@app.post("/predict", response_model=PredictionResponse)
async def predict(request: PredictionRequest):
features = np.array(request.features).reshape(1, -1)
# Check for NaN/Inf - models often return garbage for these
if not np.isfinite(features).all():
raise HTTPException(
status_code=422,
detail="Features must be finite numbers"
)
try:
loop = asyncio.get_event_loop()
pred, conf = await loop.run_in_executor(executor, _sync_predict, features)
except Exception as e:
# Log the real error, return generic message
logger.exception("Prediction failed")
raise HTTPException(
status_code=500,
detail="Prediction failed"
) from None # 'from None' suppresses chained exception in response
return PredictionResponse(prediction=pred, confidence=conf, latency_ms=...)
The from None is subtle but important. Without it, if you’re running in debug mode, the original exception might leak into the HTTP response. With from None, the exception chain is suppressed.
Running in Production
Uvicorn with multiple workers is the simplest production setup:
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
Each worker loads its own copy of the model. For a 500MB model with 4 workers, that’s 2GB of RAM just for models. On constrained environments (1GB Oracle Cloud Free Tier, for instance), you might need --workers 1 or a different approach entirely.
Gunicorn with uvicorn workers is marginally more robust:
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000
The practical difference? Gunicorn’s worker management is battle-tested across more edge cases. But for most deployments, raw uvicorn works fine. And those late-night debugging sessions? Dark Chocolate Espresso Beans are the real MVP when you’re tracing down why worker 3 keeps dying.
The Health Check Trap
Your health endpoint should verify the model is actually loaded and functional:
@app.get("/health")
async def health():
if model is None:
raise HTTPException(503, "Model not loaded")
return {"status": "healthy"}
But don’t run inference on every health check. Load balancers hit health endpoints frequently (every 5-30 seconds). If each check runs a prediction, you’re wasting compute and adding latency to the health response.
A reasonable middle ground is a separate “deep health” endpoint that does run inference:
@app.get("/health/deep")
async def deep_health():
dummy = np.zeros((1, 20))
try:
_ = model.predict(dummy)
except Exception:
raise HTTPException(503, "Model inference failed")
return {"status": "healthy", "inference": "ok"}
Hit this less frequently—maybe once a minute from monitoring—while the basic health check runs every few seconds.
What About Model Versioning?
The code above loads a single model file. In production, you’ll want to track which model version is serving. The simplest approach:
model_version = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global model, model_version
model = joblib.load("classifier.joblib")
model_version = "v1.2.3" # Or read from file, metadata, etc.
yield
@app.get("/info")
async def info():
return {"model_version": model_version, "framework": "sklearn"}
For proper experiment tracking and model registry, MLflow integrates cleanly with this pattern—load from the registry instead of a local file:
import mlflow
@asynccontextmanager
async def lifespan(app: FastAPI):
global model, model_version
model = mlflow.sklearn.load_model("models:/my-classifier/Production")
model_version = mlflow.get_model_version(...)
yield
FAQ
Q: Should I use FastAPI or Flask for model serving?
FastAPI’s async support makes it significantly better for model serving under concurrent load. Flask uses synchronous workers by default, which block on inference. You can make Flask work with gevent or gunicorn async workers, but FastAPI gives you this out of the box with cleaner code. For new projects, I’d always pick FastAPI.
Q: How do I handle GPU memory with multiple uvicorn workers?
Each worker loads the model separately, so 4 workers = 4x GPU memory usage. For GPU models, run with --workers 1 and use the ThreadPoolExecutor for concurrency instead. Alternatively, run inference in a separate process/service and have your FastAPI workers communicate with it via IPC or gRPC. This is essentially what Triton does.
Q: Why is my first request so slow even after adding warmup?
Python itself has lazy imports that trigger on first use. Libraries like numpy, torch, and sklearn load extension modules on first call to certain functions. Run your warmup with realistic inputs that exercise all code paths. For PyTorch, make sure you’re warming up on the correct device (CPU warmup doesn’t help GPU inference). Also check if your model uses lazy parameter initialization—common in transformer models.
The five steps—lifespan handlers, warmup, thread executors, Pydantic validation, and response models—transform a toy endpoint into something you can actually deploy. Most of the latency wins come from the executor pattern and warmup; the rest is operational hygiene that pays off when things go wrong.
What I haven’t figured out yet: dynamic batching without adding massive complexity. The naive implementation (buffer requests for N milliseconds, batch, run inference) adds latency to every request. The smart implementations (adaptive batching based on queue depth) need careful tuning. If you’ve nailed this in pure FastAPI, I’d genuinely like to hear about it.
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,954 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (1,047 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (982 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (897 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (705 views)