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.

Sizing from measurementsTrafficrequests per secondLatencytime per requestConcurrencyworkers neededHeadroompeaks and failureConcurrency follows from throughput times latency, then you add headroom.

Quick answer

  1. Estimate peak traffic: requests per second at the busiest expected minute, not the daily average.
  2. Measure per request: average and p95 response time, CPU time, and memory per worker.
  3. Calculate concurrency needed: peak requests per second × average response time in seconds.
  4. Choose workers and threads to exceed that concurrency with headroom, and check memory: workers × memory per worker.
  5. Size the database for connections, working set in memory, and write load.
  6. Keep at least 30 to 50 percent headroom, and N+1 redundancy if uptime matters.
  7. 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.

from users to requests per secondpeak concurrent users ×requests per user per minute÷ 60=2,000× 6 ÷ 60=200requests per second1231Page loads plus AJAX and API calls, not static files.2Peak, not daily average: use the busiest minute.3Only requests that reach Django count.With real traffic, the busiest minute in the access log divided by 60 is betterdjango-deployment.com
Add known spikes such as launches and marketing emails on top.

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_time or 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:

OptionWorkers × threadsSlotsApp memory
sync workers only50 × 150about 9 GB
gthread8 × 648about 1.5 GB
gthread12 × 448about 2.2 GB
same slots, very different memory50 sync workers × 1about 9 GB8 gthread workers × 6about 1.5 GB0 GB2.5 GB5 GB7.5 GB10 GBGBThreads share their worker’s memory; processes do notdjango-deployment.com
Threads suit I/O-bound Django views; CPU-heavy views still need processes.

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
Gunicorn is not alone on the serverGunicorn workersworkers × memory per workerCelery workersif on the same hostNginx, agents, cronsmall but not zeroOS and page cachekeep at least 20% freeSwapping under load is worse than a slightly smaller worker countdjango-deployment.com
Measure resident memory per worker after warm-up, not at start.

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_statements for 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
the worked example, step by step1Peak: 60 requests per secondto Django260 × 0.25 s = 15 in flighttarget 40 slots for bursts32 servers × 5 workers × 4 threads40 slots, ~1.1 GB each4Third server for N+1survive losing one5Pool for 60 threads + Celerydatabase sideThen prove it with a load test that also kills one serverdjango-deployment.com
The database, not the app servers, is usually the limit at this size.

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

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.

Keep going