All Articles
Performance

Adding k6 Load Tests to a Project I Immediately Refused to Run Against Production

Shahid MalikBy Shahid MalikSeptember 4, 20265 min read

Writing a real k6 script for Tabeer.ai's API, verifying it locally with actual numbers, and being explicit — in the script, in a README, in this post — about why it should never point at the live site.

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:

  1. A comment at the top of the script itself — anyone opening the file to run it sees the warning before they see the options block.
  2. backend/loadtests/README.md — the measured numbers above, plus the explicit line: "Never run this against https://tabeer.ai."
  3. 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.

Related Articles

Monitoring & Observability

Flower + Celery on Django: How I Found Silently Failing Tasks on Tabeer.ai

Users weren't getting some transactional emails. The mail-sending code looked fine, the logs weren't obviously screaming — the actual answer was Celery tasks failing with zero visibility into it. Here's the practical setup: systemd unit, basic auth, nginx subdomain, real commands.

7 min read
Shahid Malik - AI-First Odoo Consultant

Shahid Malik

AI-First Odoo ERP Specialist

Shahid Malik is an AI-first Odoo consultant helping businesses solve complex ERP and business process challenges. His work combines Odoo consulting, process optimization, automation, integrations, migrations, and practical AI solutions to build scalable and reliable business systems.

Book a consultation for your Odoo project
Discuss Your Odoo Project