Performance Optimization
Table of Contents
Why Performance Optimization Matters
In a production backend, performance is not just about “making it fast.” It affects:
- User experience (slow APIs feel broken).
- Infrastructure cost (more CPU, more RAM, more money).
- Scalability (how many users you can support).
- Reliability (overloaded systems fail in strange ways).
For your final project, performance optimization means you:
- Measure where time and resources are spent.
- Choose the right strategy for each bottleneck.
- Make changes that are safe, testable, and observable.
This chapter focuses on practical techniques you can apply to your project, not on theoretical micro-optimizations.
Key rule: Always measure before and after you optimize. Never guess where the bottleneck is, and never change code for performance without checking that it actually helps.
Step 1: Define Performance Goals
Before changing anything, decide what “good” means. Examples for an API:
- Latency: 95% of requests (P95) should complete in under 200 ms.
- Throughput: Handle at least 200 requests per second (RPS) during peak.
- Error rate: Less than 1% of requests fail.
- Resource usage: Average CPU below 70%, memory below 80% of container limit.
You do not need perfect numbers, but you do need targets so you can see if you are improving.
Example target table for an e‑commerce backend:
| Metric | Endpoint | Target |
|---|---|---|
| P95 latency | GET /products | ≤ 150 ms |
| P95 latency | POST /orders | ≤ 400 ms |
| Throughput | Overall | ≥ 150 RPS |
| Error rate | All 5xx responses | < 1% |
| DB response time | Average query time | ≤ 20 ms |
Step 2: Measure and Profile
Basic Timing in Code
If you do not have a full metrics stack yet, you can still get useful timing information.
Example: FastAPI middleware to log request duration:
import time
from fastapi import FastAPI, Request
import logging
logger = logging.getLogger(__name__)
app = FastAPI()
@app.middleware("http")
async def log_request_time(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = (start - time.perf_counter()) * -1000 # ms
logger.info(
"method=%s path=%s status=%s duration_ms=%.2f",
request.method,
request.url.path,
response.status_code,
duration,
)
return responseThis gives you per-request latency in logs. You can use it to see which endpoints are slowest.
Profiling CPU-bound Code
If a specific function seems slow, you can profile it.
Example profiling script:
import cProfile
import pstats
from myapp.some_module import slow_function
if __name__ == "__main__":
profiler = cProfile.Profile()
profiler.enable()
slow_function()
profiler.disable()
stats = pstats.Stats(profiler).sort_stats("cumulative")
stats.print_stats(20) # show top 20 functionsLook at:
- Cumulative time: total time including subcalls.
- Per-call time: time per call, to see if the function is too often or too slow.
Using Load Testing Tools
You must test the application under load, not just locally by hand. Common tools:
locustk6wrk
Simple wrk example:
wrk -t4 -c100 -d30s http://localhost:8000/products-t44 threads.-c100100 open connections.-d30sfor 30 seconds.
You will get latency percentiles and RPS. Compare these numbers before and after changes.
Important: Always load test non-production environments with similar configuration. Never stress your real users and production database when experimenting.
Step 3: Identify the Bottleneck
Performance problems usually fall into one of these categories:
| Category | Symptoms | Typical Fixes |
|---|---|---|
| DB bound | Slow queries, DB CPU high, many I/O waits | Indexes, query optimization, caching, batching |
| I/O bound | Waiting on external APIs, file or network | Async I/O, caching, queues, parallel requests |
| CPU bound | High CPU usage, tight loops, heavy compute | Algorithm changes, offloading to background workers |
| Concurrency / lock bound | Many requests, low throughput | Connection pooling, fewer locks, better architecture |
Use logs and metrics:
- Slow endpoints: check logs for per-route timings.
- Database metrics: slow query logs, query plans.
- External dependencies: HTTP call timing.
Do not optimize everything. Start with the top 1 or 2 bottlenecks.
Database Performance
In a typical backend, the database is often the first real bottleneck.
Use Efficient Queries
Common problems:
- N+1 queries.
- Selecting too many columns.
- Unnecessary joins or nested subqueries.
Example of N+1 problem:
# Inefficient: one query for users, and one per user for orders
users = session.query(User).all()
for user in users:
print(user.name, len(user.orders)) # triggers a query per userBetter, use eager loading:
from sqlalchemy.orm import selectinload
users = (
session.query(User)
.options(selectinload(User.orders))
.all()
)
for user in users:
print(user.name, len(user.orders)) # no extra queriesUse Indexes Wisely
Indexes speed up WHERE, ORDER BY, and JOIN conditions.
For example, a slow query:
SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20;Add an index to support this pattern:
CREATE INDEX idx_orders_user_created_at
ON orders (user_id, created_at DESC);You can inspect query plans in PostgreSQL:
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20;Look for:
- Sequential scan on large tables where an index scan would be better.
- Large row estimates or large actual row counts.
Rule: Always confirm index usefulness with EXPLAIN ANALYZE. An index that is never used just slows down writes and takes disk.
Limit Data Returned
Returning a lot of rows or large payloads slows everything.
Techniques:
- Pagination: Always paginate lists, use
LIMITandOFFSETor cursor-based pagination. - Select only needed columns: For heavily used endpoints, avoid
SELECT *.
Example:
SELECT id, name, price
FROM products
WHERE category_id = 5
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;In SQLAlchemy:
products = (
session.query(Product.id, Product.name, Product.price)
.filter(Product.category_id == category_id)
.order_by(Product.created_at.desc())
.limit(limit)
.offset(offset)
.all()
)Using Caching Effectively
Caching can give huge gains with minimal code changes, especially for read-heavy endpoints.
Cache Where It Makes Sense
Ideal candidates:
- Public, read-heavy endpoints:
GET /products,GET /categories. - Rarely changing data: product categories, configuration.
- Expensive derived data: aggregated statistics.
Types of caches in your project:
| Level | Example | Use case |
|---|---|---|
| Application cache | In-memory dict in a process | Very fast, but not shared |
| Redis | Key-value data across instances | Shared cache for multiple replicas |
| HTTP cache | Client or proxy cache (CDN) | Public data, static responses |
Simple Redis Cache Pattern
Example: cache product list by category.
import json
from typing import List
import aioredis
from fastapi import Depends
redis = aioredis.from_url("redis://redis:6379", decode_responses=True)
CACHE_TTL_SECONDS = 60
async def get_products_by_category(category_id: int) -> List[dict]:
cache_key = f"category:{category_id}:products"
cached = await redis.get(cache_key)
if cached is not None:
return json.loads(cached)
# Fallback to DB
products = (
session.query(Product)
.filter(Product.category_id == category_id)
.order_by(Product.created_at.desc())
.all()
)
data = [p.to_dict() for p in products]
await redis.set(cache_key, json.dumps(data), ex=CACHE_TTL_SECONDS)
return dataCache Invalidation
The hard part of caching is invalidation.
Examples:
- When a product is updated, delete its cache key.
- When a category changes, delete category-related cache keys.
Example invalidation on update:
async def update_product(product_id: int, data: ProductUpdate):
product = session.get(Product, product_id)
# ... update fields ...
session.commit()
# Invalidate cache
product_key = f"product:{product_id}"
category_key = f"category:{product.category_id}:products"
await redis.delete(product_key, category_key)
return productRule: If you are not 100% sure that cached data is fresh enough, prefer correctness over caching. Do not serve stale or wrong data only to be faster.
Asynchronous I/O and Concurrency
Backends often spend time waiting on I/O, such as:
- Database queries.
- Calls to other services (payment provider, email API).
- File operations and network calls.
You can use async endpoints to handle more requests without blocking.
Async Endpoints for I/O Bound Work
Example: calling an external payment API.
import httpx
from fastapi import APIRouter
router = APIRouter()
@router.post("/payments")
async def create_payment(order_id: int):
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
"https://payment.example.com/charge",
json={"order_id": order_id},
)
response.raise_for_status()
return response.json()This does not block the server worker while waiting for the payment service.
Do Not Use Async for CPU-Bound Work
Long, CPU-heavy tasks should run in background workers, not directly inside API requests.
If you must run some CPU work synchronously, consider returning early and processing it with a queue, which you learned about in the background workers chapter.
Reducing Work in the Request Path
A simple, powerful strategy is to move non-critical work out of the request path.
Good candidates:
- Sending emails.
- Generating PDFs.
- Large image processing or data imports.
- Slow third-party integrations that are not needed immediately.
Use Background Jobs
Example: create order now, send email later.
from fastapi import APIRouter, BackgroundTasks
router = APIRouter()
def send_order_confirmation_email(order_id: int, email: str):
# slow function, external SMTP or API calls
...
@router.post("/orders")
async def create_order(order_data: OrderCreate, background_tasks: BackgroundTasks):
order = create_order_in_db(order_data)
background_tasks.add_task(
send_order_confirmation_email,
order_id=order.id,
email=order.customer_email,
)
# Response is fast, email is sent in background
return {"id": order.id, "status": "created"}For more complex workloads, use Celery or similar workers, already covered earlier.
Efficient Serialization and Responses
Serialization and response building can also be a source of latency, especially when you return large objects.
Return Only What Clients Need
Example, instead of this:
class Product(BaseModel):
id: int
name: str
description: str
price: float
created_at: datetime
updated_at: datetime
internal_notes: strFor a public listing endpoint, define a smaller response model:
class ProductListItem(BaseModel):
id: int
name: str
price: floatUse that in your endpoint:
@router.get("/products", response_model=List[ProductListItem])
async def list_products(...):
...Less data means:
- Less CPU to serialize.
- Smaller JSON.
- Lower network latency.
Avoid Unnecessary Work in Response Building
Be careful with operations like:
- Sorting large lists in Python instead of in SQL.
- Repeatedly converting DB models to dicts in slow ways.
- Copying big data structures.
If possible, let the database handle sorting and filtering, and construct your response in a straightforward way.
Connection Pooling and Resource Limits
When your backend scales to multiple replicas, connection usage becomes critical.
Database Connection Pooling
Using SQLAlchemy or async drivers, you should:
- Reuse connections via a pool.
- Avoid opening a new connection per request.
Example SQLAlchemy configuration:
engine = create_engine(
DATABASE_URL,
pool_size=10,
max_overflow=5,
pool_timeout=30,
)You must size the pool based on:
- Max connections allowed by PostgreSQL.
- Number of application instances.
- Average concurrency per instance.
Example calculation:
If PostgreSQL max_connections = 200, and you run 5 app instances, you might give each instance:
pool_size = 20- Some headroom for admin and monitoring connections.
$$
\text{max\_pool\_connections\_total} = \text{instances} \times \text{pool\_size}
$$
You want:
$$
\text{max\_pool\_connections\_total} \le \text{max\_connections} - \text{reserved\_connections}
$$
Rule: If you see “too many connections” errors or high DB CPU, do not just increase max connections. First, check and tune your pools and queries.
HTTP Client Pooling
For external services, reuse HTTP clients where possible:
import httpx
client = httpx.AsyncClient(timeout=5.0)
@router.get("/external")
async def call_external():
resp = await client.get("https://api.example.com/data")
return resp.json()Creating a new client per request wastes time, sockets, and memory.
Using HTTP Caching and Compression
HTTP Caching Headers
When responses are cacheable, you should set headers like:
Cache-ControlETagLast-Modified
Example FastAPI response with headers:
from fastapi import Response
@router.get("/public-products")
async def public_products():
data = get_public_products()
body = json.dumps(data)
headers = {"Cache-Control": "public, max-age=60"}
return Response(content=body, media_type="application/json", headers=headers)Clients or proxies can then reuse responses instead of hitting your server again.
Compression
Most clients support gzip or similar. Use your web server or ASGI server to compress responses.
This reduces bandwidth for mid-to-large responses but adds some CPU. For JSON APIs, compression is almost always beneficial.
Optimize Critical Paths First
Not all endpoints are equal. Focus on:
- High-traffic endpoints, like
GET /products,GET /cart,POST /orders. - Expensive endpoints, like complex filters or reports.
A typical improvement loop for a single endpoint:
- Measure baseline latency and RPS with load testing.
- Inspect logs and DB queries for that endpoint.
- Apply one improvement:
- Add index.
- Add small cache.
- Remove unnecessary work.
- Load test again.
- Compare metrics.
Example results table:
| Change | P95 latency | RPS | Notes |
|---|---|---|---|
| Baseline | 420 ms | 80 | N+1 queries, no cache |
| Add eager loading | 260 ms | 120 | Fewer DB roundtrips |
| Add Redis cache (60s) | 90 ms | 300 | DB load reduced significantly |
Avoid Common Anti-patterns
Some patterns look simple but can destroy performance.
Doing Work in Loops That Could Be Batched
Slow:
orders_summary = []
for order_id in order_ids:
order = session.get(Order, order_id)
orders_summary.append(order.to_summary())Faster:
orders = (
session.query(Order)
.filter(Order.id.in_(order_ids))
.all()
)
orders_summary = [o.to_summary() for o in orders]Fetching All Rows Without Need
Avoid SELECT * FROM big_table without limits.
At API level, never design an endpoint that “returns all entries” for large collections. Always require pagination.
Synchronous Blocking in Async Code
In async endpoints, do not call blocking functions directly, such as:
- Synchronous database drivers.
- Synchronous HTTP clients.
- Heavy CPU-bound operations.
They block the event loop and reduce concurrency.
Validate Performance With Tests
You do not need full performance tests in your unit test suite, but you should:
- Keep simple smoke load tests that you can run after major changes.
- Run heavier load tests before releases.
You can also add simple timing asserts in integration tests for critical paths:
import time
def test_products_endpoint_is_fast(client):
start = time.perf_counter()
response = client.get("/products?limit=20")
duration = time.perf_counter() - start
assert response.status_code == 200
assert duration < 0.5 # 500 msThese do not guarantee real-world performance, but they help catch regressions.
Putting It All Together for the Final Project
For your production-ready final project, a practical performance optimization plan might look like this:
- Set targets
- For example, P95 latency ≤ 250 ms for main GET endpoints.
- Instrument your app
- Add request timing middleware.
- Log slow queries from PostgreSQL.
- Enable basic metrics collection.
- Optimize database usage
- Fix N+1 patterns.
- Add missing indexes for most common filters and joins.
- Implement pagination everywhere lists are returned.
- Introduce caching
- Use Redis for expensive, frequently read endpoints.
- Invalidate cache entries on writes.
- Move non-critical work to background
- Emails, some external integrations, heavy processing.
- Tune connection pools
- Configure reasonable DB pool sizes for the number of app instances.
- Reuse HTTP clients.
- Run load tests
- Before deployment and after significant changes.
- Compare against your targets and adjust.
- Monitor in production
- Track latency, throughput, error rate, and resource usage.
- Set alerts when metrics exceed thresholds.
Final principle: Performance optimization is a continuous process, not a one-time task. Measure, improve, deploy, and observe, then repeat when usage patterns or requirements change.
With these practices, your final project will not only function correctly, it will also handle real-world traffic and grow with your users.
Views: 5
KAHIBARO