Find your breaking point before your users do: designing realistic load scenarios, driving them with Locust or k6, reading the latency percentiles that matter, locating the true bottleneck, and turning results into capacity plans and autoscaling signals.
Every app has a point where added load turns healthy latency into a cliff — requests queue, timeouts cascade, and the site falls over. The only question is whether you find that cliff in a controlled test or during your busiest hour in production. This tutorial covers load-testing Django properly: designing realistic scenarios, driving them with Locust or k6, reading the percentiles that matter, finding the true bottleneck, and turning results into capacity plans and autoscaling signals rather than a reassuring but meaningless "it handled a thousand requests".
Functional tests prove the app is correct for one user; they say nothing about what happens under a thousand concurrent ones. Systems do not degrade gracefully — they are fine, fine, fine, then they hit a resource limit (connections, CPU, a lock) and collapse non-linearly. Load-testing finds that limit deliberately, so you know your real capacity, can plan for growth and traffic spikes, and can prove a change made things faster rather than hoping. Skipping it means your first true capacity test is a real incident with real users.
Three distinct tests answer three questions. A load test holds an expected level of traffic and checks the system meets its latency targets there. A stress test pushes past expected load to find the breaking point and see how it fails — cleanly with shed load, or catastrophically. A soak test runs moderate load for hours to surface slow problems: memory leaks, connection exhaustion, disks filling, caches degrading. You need all three eventually; they catch different classes of failure and a system can pass one while failing another.
The most common load-testing mistake is testing something users never do — hammering one cheap endpoint and declaring victory. Real traffic is a mix: some users browse, some search, a few check out, each with think-time pauses between actions. Model that. Weight the scenarios by real proportions from your analytics, include the expensive paths (search, checkout, report generation) that actually strain the system, and add pauses so you simulate users rather than an unrealistic tight loop. A test that mirrors real behavior predicts real capacity; one that does not produces confident nonsense.
Two tools dominate. Locust is Python, so scenarios are Django-developer-friendly and easy to make dynamic, with a live web UI. k6 is a Go-based tool scripted in JavaScript, excellent for CI and high load per machine. Either works; pick Locust if you want to stay in Python and reuse app knowledge, k6 if you want a lean binary and first-class automation. Avoid ad-hoc tools like a bare ab loop for anything beyond a smoke check — they cannot express realistic multi-step user behavior.
A Locust scenario is a class of tasks with weights and wait times, expressed in plain Python.
from locust import HttpUser, task, between
class ShopUser(HttpUser):
wait_time = between(1, 5) # think-time between actions
@task(10)
def browse(self):
self.client.get("/products/")
@task(3)
def search(self):
self.client.get("/search/?q=django")
@task(1)
def checkout(self):
self.client.post("/cart/add/", json={"sku": "ABC"})
self.client.post("/checkout/")
The weights encode the real mix — ten browses per checkout — and between(1, 5) gives each simulated user human pauses. Run it ramping users up gradually and watch where latency starts to climb.
Three numbers matter together, and no single one is enough. Throughput (requests per second) is capacity. Latency is the user experience, but only as a distribution, not an average. And error rate is the honesty check — throughput means nothing if a third of responses are 500s or timeouts. A system "handling" high throughput while erroring or timing out is not handling it; always read all three at once.
Average latency is the most misleading number in performance work. A mean of 100ms can hide that one request in a hundred takes three seconds — and on a page that makes twenty backend calls, almost every page hits that slow tail. Read percentiles: p50 (median) is the typical experience, p95 and p99 are the tail that real users feel and that averages bury. Set targets on the tail — "p99 under 500ms" — because the worst-case latency, multiplied across a page's many requests, is what determines whether the site feels fast or flaky.
A load test tells you the system slowed down; it does not tell you why. To find the cause, watch resource metrics on every tier during the test — application CPU, database CPU and connection count, cache hit rate, external-API latency. The bottleneck is whatever saturates first: usually the database (connection exhaustion or a missing index turning slow under concurrency), sometimes CPU, sometimes a rate-limited upstream. Correlate the latency climb with which resource maxed out, because optimizing anything other than the actual bottleneck moves no needle.
A load test is only as trustworthy as its environment. Testing against an empty database hides the index and query problems that appear at real data volume; testing on a laptop tells you nothing about production's network and infrastructure. Run against an environment that mirrors production — comparable instance sizes, a database loaded with production-scale (anonymized) data, the real cache and proxy in front. The most common way load tests lie is by being too easy: a fast result on a tiny, empty, local system that has nothing to do with production reality.
Turn numbers into decisions. If one instance sustains 200 requests per second at acceptable latency and you expect peaks of 800, you need four instances plus headroom for failures and spikes — never plan to run at 100% of tested capacity, because there is no margin for a bad deploy or a traffic surge. Little's Law (concurrency = throughput × latency) helps size worker and connection pools from your measured numbers. The output of load-testing is a defensible statement: "we can serve X, we provision for Y, we scale at Z".
Load-testing also tells you what to autoscale on. The right signal is the one that predicts saturation for your workload — often request concurrency or latency rather than CPU, since an I/O-bound Django app can be at its limit while CPU looks calm. Use the test to find which metric moves first as you approach the cliff, and scale on that, with thresholds set below the breaking point so new capacity comes online before users feel pain rather than after.
Performance regresses silently — an N+1 sneaks in, a cache is disabled, a dependency slows down — and you want to catch it in a pull request, not a postmortem. Run a scaled-down load test in CI against a representative environment and fail the build if p99 or throughput regresses beyond a threshold. Treating performance as a tested, gated property rather than something checked once before a big launch is what keeps an app fast as it changes over months and years.
Beyond empty databases and localhost testing, the recurring traps are: warm-vs-cold caches, where a test that runs long enough to warm every cache overstates capacity for real cold-start traffic; the load generator itself becoming the bottleneck, so you measure its limits not the server's; unrealistic uniform data that makes every cache hit and every query cheap; and reading only averages. Each one makes the numbers prettier and the conclusion wronger. A load test's job is to be honest, and most bad ones are simply too easy on the system.
How you apply load matters as much as how much. Slamming a system with full load instantly measures cold-start behavior — empty caches, unconnected pools — which is a real but different scenario from sustained peak. Use a ramp profile that adds users gradually so you can watch latency climb and pinpoint the exact concurrency where it turns the corner, and include a warm-up period so steady-state numbers are not skewed by initialization. The shape of the load curve is a parameter of the experiment; choose it to answer the question you actually have, whether that is "how do we handle a slow build-up" or "how do we survive a sudden spike".
One machine can only generate so much traffic before it becomes the bottleneck, and then you are measuring your load generator, not your server. For high target loads, run the generator distributed across several workers — both Locust and k6 support this — and confirm the generators themselves are not saturated on CPU or network. A telltale sign you have hit this trap is latency that plateaus no matter how many virtual users you add: often the server has room and the load box is maxed out.
Realistic scenarios are not static URLs — users log in, receive session and CSRF tokens, get back ids they use in the next request. Your load script must correlate this: capture the token from a login response and send it onward, use returned ids in follow-up calls, and vary inputs so every virtual user is not hitting the identical cached row. Static, uncorrelated scripts both fail against real auth and paint an unrealistically cache-friendly picture. Handling dynamic data is what separates a genuine end-to-end load test from a trivial URL hammer.
Plot latency against concurrency and you get a characteristic shape: flat and healthy, then a knee where it bends sharply upward as a resource saturates and requests start queuing. That knee is your practical capacity — not the point where errors begin, which is already past the cliff. Your target operating load should sit comfortably left of the knee, with the gap as your headroom. Learning to spot the knee, rather than chasing the maximum requests-per-second before total collapse, is the core skill of reading load-test results.
Streaming responses, server-sent events, and WebSockets need different tests than request-response endpoints, because their cost is in held open connections over time, not requests per second. Measure how many concurrent long-lived connections a worker sustains and what happens as that number climbs, using a tool that can hold connections open (k6 supports WebSockets; Locust can with an appropriate client). An HTTP-only load test badly understates the resource pressure of an app built around live connections, so test the connection model you actually run.
Load-test before any major launch or expected traffic event, after significant architectural changes, and continuously in a lightweight form as a regression guard. You do not need a full stress campaign every week, but you should never discover your capacity for the first time during a real spike. The goal is to always know, with evidence, roughly where your cliff is and to be provisioned comfortably below it — so growth is a planning decision, not an emergency.