Size a Server for a Django App: Capacity Planning
Estimate CPU, memory, and database capacity for Django: measure per-request costs, calculate worker counts, plan headroom, load test, and scale.
Problem statement
"How big a server do I need for Django?" has no universal answer, which is why many teams either overpay for oversized machines or discover limits during a launch. Capacity depends on what your requests do: a site serving cached marketing pages behaves very differently from an API that runs complex queries or a dashboard that renders large tables.
Guessing leads to common failures:
- too many Gunicorn workers for the available memory, causing OOM kills under load
- too few workers, so requests queue while CPU sits idle
- application servers scaled up while the database is the actual bottleneck
- no headroom for deploys, cron jobs, traffic spikes, or a failed server
This guide gives a repeatable method: measure per-request costs, derive worker counts and server sizes, plan headroom, and validate with load tests.
Quick answer
- Estimate peak traffic: requests per second at the busiest expected minute, not the daily average.
- Measure per request: average and p95 response time, CPU time, and memory per worker.
- Calculate concurrency needed: peak requests per second × average response time in seconds.
- Choose workers and threads to exceed that concurrency with headroom, and check memory: workers × memory per worker.
- Size the database for connections, working set in memory, and write load.
- Keep at least 30 to 50 percent headroom, and N+1 redundancy if uptime matters.
- Validate with a load test against a production-like environment, then monitor utilization and revisit quarterly.
Step-by-step solution
1. Estimate peak load
Start from real data if you have it:
# Requests per minute from the Nginx access log, busiest minutes
sudo awk '{print substr($4, 2, 17)}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -5
Divide the busiest minute's count by 60 for peak requests per second. For new projects, estimate from expected users:
peak concurrent users × requests per user per minute ÷ 60 = peak requests per second
Example: 2,000 concurrent users each making 6 requests per minute (page loads, AJAX, API calls) gives 200 requests per second. Separate static file requests (served by Nginx or a CDN) from requests that reach Django.
Plan for known spikes: marketing emails, launches, seasonal peaks, and bots.
2. Measure per-request cost
Gather, per important endpoint:
- response time (p50 and p95) from access logs with
$upstream_response_timeor APM - CPU time: how much of the response time is spent computing versus waiting on the database or network
- memory per worker after warm-up
A simple memory measurement:
ps -o pid,rss,cmd -C gunicorn --sort=-rss | head
RSS is in kilobytes. Take the steady-state value after the application has handled traffic for a while, not right after boot.
If CPU time is a small fraction of response time, requests are I/O-bound (typical Django apps waiting on PostgreSQL and APIs), and threads can add concurrency cheaply. If CPU time is close to response time, requests are CPU-bound, and concurrency is limited by cores.
3. Calculate required concurrency
Concurrency needed at peak:
concurrent requests ≈ peak requests per second × average response time (seconds)
Example: 200 requests per second × 0.12 seconds average = 24 requests in progress at any moment on average. Traffic arrives unevenly, so plan for p95 response times and short bursts, roughly doubling this for a working target: about 50 concurrent request slots.
4. Translate concurrency into workers and threads
With Gunicorn gthread workers:
request slots = workers × threads
Constraints:
- CPU: CPU-bound work cannot exceed the number of cores in parallel. A common starting point is 2 to 4 workers per core for mixed workloads, fewer for CPU-heavy apps.
- Memory: total app memory ≈ workers × memory per worker (threads share their worker's memory).
- Database connections: each thread that queries uses a connection.
Example for 50 slots on a 4-vCPU server with 180 MB per worker:
| Option | Workers × threads | Slots | App memory |
|---|---|---|---|
| sync workers only | 50 × 1 | 50 | about 9 GB |
| gthread | 8 × 6 | 48 | about 1.5 GB |
| gthread | 12 × 4 | 48 | about 2.2 GB |
For an I/O-bound app, 8 workers with 6 threads on 4 vCPUs with 8 GB RAM leaves room for the OS, Nginx, and spikes. The sync-only option would need a much larger machine for the same concurrency.
5. Add everything else on the server
List every process sharing the machine:
Gunicorn workers 1.5 GB
Celery workers 1.0 GB
Nginx 0.1 GB
Redis 0.5 GB (maxmemory + overhead)
PostgreSQL (if local) 2.0 GB (shared_buffers + connections + work_mem)
OS and page cache 1.5 GB minimum
Headroom 30 to 50 percent
A shared single server with all components can work well for small and medium sites; as load grows, the database usually moves to its own machine first.
6. Size the database
Database capacity is often the real limit:
- Connections: total from all app servers, workers, and tools must stay below
max_connections; add pooling when it does not. - Memory: the working set (frequently accessed tables and indexes) should fit in RAM for consistent performance. Check database size with
SELECT pg_size_pretty(pg_database_size(current_database()));and index sizes for the hottest tables. - CPU: complex queries, sorts, and aggregations consume database CPU; check
pg_stat_statementsfor the most expensive statements by total time. - Disk I/O: write-heavy workloads need fast storage; watch I/O wait and checkpoint behavior.
Estimate database CPU need from query time: if requests spend on average 40 ms in queries at 200 requests per second, the database needs about 8 CPU-seconds per second of work, so at least 8 busy cores worth of capacity at peak unless you reduce query time. That calculation often reveals that optimizing a few queries is cheaper than scaling hardware.
7. Plan headroom and redundancy
Target peak utilization:
- CPU below about 60 to 70 percent at expected peak
- memory below about 70 to 80 percent, with no swapping
- database connections below 70 to 80 percent of the limit
For availability, use N+1: if two app servers are needed at peak, run three, so one can fail or be taken down for deploys without overloading the rest.
8. Validate with a load test
Estimates are hypotheses. Test them against a staging environment that matches production sizes:
# locustfile.py
from locust import HttpUser, between, task
class Visitor(HttpUser):
wait_time = between(1, 5)
@task(5)
def home(self):
self.client.get("/")
@task(3)
def product_list(self):
self.client.get("/products/")
@task(1)
def search(self):
self.client.get("/search/?q=chair")
locust -f locustfile.py --host https://staging.example.com --headless -u 500 -r 20 -t 10m
Increase users until p95 latency or error rate crosses your target. While it runs, record CPU, memory, database CPU, connection counts, and Gunicorn queueing. The first resource to saturate tells you what to scale or optimize next.
9. Monitor and revisit
Capacity planning is continuous:
- dashboards for request rate, p95 latency, CPU, memory, and database connections
- alerts on sustained high utilization, not just outages
- a quarterly review comparing growth with remaining headroom
- a re-test after major features, framework upgrades, or traffic changes
10. Worked example
A B2B dashboard app:
- peak: 60 requests per second to Django
- average response time: 250 ms (p95 800 ms), mostly database time
- memory per worker: 220 MB
Concurrency: 60 × 0.25 = 15 average, target about 40 slots for bursts and slow requests.
App servers: two servers with 4 vCPU and 8 GB each, running 5 workers × 4 threads (20 slots each, 40 total, about 1.1 GB app memory per server), plus a third server for N+1.
Database: queries dominate response time, so first optimize the top statements from pg_stat_statements; then choose a database instance with enough RAM to hold hot indexes and connection pooling for 3 servers × 20 threads plus Celery.
Verification check:
- load test reaches expected peak with p95 latency and error rate within targets
- CPU and memory stay below planned utilization at peak
- losing one app server during the test keeps latency acceptable
- database connections and CPU stay within limits
Explanation
A Django server's capacity is the number of requests it can handle concurrently without exhausting CPU, memory, or database resources, divided by how long each request holds those resources. Measuring response time and per-worker memory turns traffic estimates into concrete worker, thread, and server counts. Load tests confirm the model and reveal the first real bottleneck, which is frequently the database rather than the application servers.
Edge cases / notes
Burstable cloud instances
Burstable CPU instance types accumulate credits when idle and throttle when credits run out. They can look fine in short tests and slow down under sustained load. Load test long enough to see throttling.
Background workloads
Scheduled jobs and Celery tasks consume capacity at specific times. Include their peaks, or run them on separate machines.
ASGI and async
Async views change the concurrency model for I/O-heavy endpoints, but synchronous ORM calls still run in threads and still need database connections. Measure rather than assume large gains.
Common mistakes
- Planning from daily averages instead of peak minutes.
- Choosing worker counts from CPU cores alone and ignoring memory.
- Scaling app servers while the database is saturated.
- Load testing a smaller staging environment and extrapolating linearly.
Next steps
- Configure workers with How to Optimize Gunicorn Workers for Django.
- Validate your plan with How to Load Test a Django App Before Release.
- Add servers safely with Scale Django Horizontally Behind a Load Balancer.
- Avoid memory exhaustion with Fix Gunicorn Workers Killed by the OOM Killer.
FAQ
How many requests per second can a Django server handle?
It depends on response time and resources per request. A server with 40 concurrent request slots and 100 ms average response time can handle roughly 400 requests per second in theory; measure your app to know.
How much RAM does a Django application need?
Multiply memory per Gunicorn worker (often 60 to 250 MB) by the worker count, then add Celery, Redis, the database if local, the operating system, and 30 percent or more headroom.
How do I calculate the number of Gunicorn workers?
Estimate required concurrent requests from peak traffic and response time, then choose workers and threads to exceed that while staying within CPU and memory limits.
When should I move the database to its own server?
When database CPU, memory, or I/O competes with the application on the same machine, or when you need to scale application servers independently. It is usually the first split as traffic grows.