Architecture, caching, background jobs, and the hard lessons from production.
A million requests a day is roughly a dozen requests per second on average, with peaks several times higher. Django handles that comfortably — but only if you stop treating the database as a free resource and move slow work out of the request path. Here is how we get there in production.
Start by measuring, not guessing
Before optimizing anything, instrument the application. Add request tracing and database query logging, and watch the slow endpoints. The bottleneck is almost never where intuition says it is; in most Django apps it is the database, and specifically the number of queries per request.
Tame the ORM
The single biggest win is eliminating N+1 queries. Use select_related for foreign keys and prefetch_related for reverse and many-to-many relations so a list endpoint runs a handful of queries instead of hundreds. Add database indexes for every field you filter or order by, and use only() and values() to stop fetching columns you never render.
Cache the expensive and the repeated
Put Redis in front of the work that is costly to compute and changes slowly. Cache at several layers: whole responses for anonymous traffic, expensive querysets keyed by their inputs, and a per-request cache for values read many times in one view. The discipline that matters most is invalidation — cache with explicit keys and clear them on write, rather than relying on short timeouts and hoping.
Get slow work out of the request
Sending email, generating reports, calling third-party APIs and processing uploads do not belong in the request/response cycle. Move them to a background worker — Celery with Redis or a similar queue — so the user gets an immediate response and the heavy lifting happens asynchronously. This single change often does more for tail latency than any query tuning.
Scale the boring parts well
- Connection pooling. A pooler such as PgBouncer keeps the database from drowning in connections as you add app servers.
- Read replicas. Route reporting and read-heavy traffic to a replica so writes stay fast.
- Run stateless. Keep no session state on the app server so you can add instances behind a load balancer freely.
- Set sane timeouts. Every external call needs a timeout and a fallback, or one slow dependency takes the whole service down.
The hard lessons
Two things bite teams repeatedly. First, caching without an invalidation strategy trades correctness bugs for performance — design the invalidation before you add the cache. Second, scaling app servers while ignoring the database just moves the queue; the database is usually the real ceiling, so spend your effort there first. Get the queries, the cache and the background jobs right, and a single well-provisioned Django deployment handles a million requests a day without drama.