Adding k6 load-test tooling to a project is normally about answering "how does this hold up under traffic." For Tabeer.ai specifically, the more useful thing k6 answers first is "how does this hold up under traffic locally, because finding out on the live server is not an option" — worth being upfront about, since it shapes every decision in the actual script.
Install
brew install k6
The Script
// backend/loadtests/api_smoke.js
import http from 'k6/http'
import { check, sleep } from 'k6'
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8000'
export const options = {
vus: 5,
duration: '30s',
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<800'],
},
}
export default function () {
const endpoints = [
'/api/billing/plans/',
'/api/content/stats/',
'/api/subjects/?exam=ECAT',
'/api/tests/?exam=ECAT',
]
for (const path of endpoints) {
const res = http.get(`${BASE_URL}${path}`)
check(res, { [`${path} returns 200`]: (r) => r.status === 200 })
}
sleep(1)
}
Same BASE_URL env-var pattern as the Playwright setup
— defaults to localhost, requires an explicit override to point anywhere else. Not a
coincidence; it's the same underlying problem with the same shape of fix.
Actual Numbers, Not Assumed Ones
k6 run backend/loadtests/api_smoke.js
checks_succeeded...: 100.00% 560 out of 560
http_req_failed....: 0.00% 0 out of 560
http_req_duration..: avg=20.39ms p(95)=37.62ms
http_reqs...........: 560 18.435974/s
5 virtual users, 30 seconds, 560 total requests, both thresholds passed — http_req_failed
under 1% and p(95) under 800ms with real margin to spare. That's the useful output: not "k6
is installed," but "here's what this API's actual latency and error rate look like under a
small amount of concurrent load, measured, not guessed."
Why "Never Against Production" Isn't Caution for Its Own Sake
This is the specific part worth being blunt about, because the reasoning is concrete, not theoretical: the production EC2 instance backing this app has 908MB of total RAM, and has been OOM-killed by completely normal, single-user, single-request traffic multiple times already — not under any kind of load, just regular usage landing at an unlucky moment alongside a deploy or a build. A load test, by definition, generates concurrent traffic on purpose. Even the conservative 5 VUs / 30 seconds above, run against that same box, is a real and non-hypothetical risk of taking the site down — this isn't "be careful with a powerful tool," it's "this specific server has already demonstrated it can't reliably absorb far less than what this script would throw at it."
That reasoning is written in three places, deliberately redundant:
- A comment at the top of the script itself — anyone opening the file to run it sees the
warning before they see the
optionsblock. backend/loadtests/README.md— the measured numbers above, plus the explicit line: "Never run this againsthttps://tabeer.ai."- This post.
Redundant on purpose. A safety note that exists in exactly one place is one missed README away from not existing at all.
What This Setup Is Actually For
Answering real, useful questions before they become production incidents: does a new endpoint hold up under concurrent access, does adding a database index change the p95 under load, does a new Celery task's synchronous DB writes bottleneck under 5 concurrent requests. All answerable safely, repeatedly, against a local Django dev server — which is exactly where this script points by default, and exactly where it should stay pointed for a project running on infrastructure this size.
If you're setting up load testing for a project and want the tooling to make the safe choice the easy one by default, get in touch.
By Shahid Malik