Load testing: finding the limits before a crowd does
The scalability reports (AWS) found
their bottlenecks by reading code and pointing ab at one URL at a time.
That was enough to rank them, but nobody can repeat it. It doesn’t catch
the next bottleneck a pull request adds, and it never tried a real mix of
learners. This page proposes a repeatable way to spike-test the app
ahead of time. It is a proposal: none of the files named below exist yet.
Short version. Use k6 (one brew install, nothing added to the
app). Write one scenario file that plays a learner the way the
reports describe one: sign up, open a course, read, answer every ~6 s,
leave after ~20 min. Run it in three places, each closer to
production than the last:
- The Mac, against
./.dev-start --release. Ten minutes. Finds code bottlenecks. - A Docker container on the Colima runners, with the AWS unit’s CPU and memory limits. Thirty minutes. Gives Linux memory and small-box numbers.
- A throwaway copy of the AWS stack, before a launch or press. An hour, under a dollar. Gives real TLS, Caddy, CPU credits and network.
Each run passes or fails on numbers written in the scenario file, so it answers “did this get worse?” without anyone reading graphs. Setup takes about two days.
1. Why not just ab again
| Need | ab | k6 |
|---|---|---|
| A different e-mail address per sign-up | no (one fixed body) | yes |
| A cookie jar per learner | no (one shared cookie) | yes, automatically per virtual user |
| A mix: pages, answers, finishes, sign-ups | one URL per run | weighted scenarios in one run |
| A spike: 0 → 3× peak in a minute | no | ramping-arrival-rate |
| Arrivals that keep coming when the server slows (as real users do) | no (closed loop) | yes (open model), so queues show up as queues |
| Pass/fail | no | thresholds set the exit code |
| Numbers per endpoint | no | tags, plus a JSON summary to diff |
The alternatives to k6:
- oha: keep it for single-URL micro-benchmarks. It is
abwith better output. - Locust: needs Python and a worker process per core.
- Artillery: adds an npm tree.
- Playwright (already in
e2e/): one browser per learner, so a laptop manages a few dozen, not 1,500. It stays the tool for correctness.
k6 is one static binary, scenarios are plain JavaScript, and it can drive a few thousand virtual users from one laptop.
2. What to add to the repo
All of it is dev-only. Nothing is compiled into the app or shipped.
| File | What it holds |
|---|---|
load/learner.js | The virtual learner and visitor (section 3), and one k6 scenario per profile (section 4), chosen with -e PROFILE=spike. |
load/thresholds.js | Pass/fail numbers per profile, in one place. |
scripts/load-test.sh | --where mac|docker|URL, --profile …. Starts the target, seeds it, runs k6, samples the server while it runs (section 5), checks the server-side facts, and writes load/results/<date>-<profile>.json. |
load/baseline.json | The last accepted results per profile and place, like scripts/panic-baseline.txt. The script prints the difference and fails on a >25 % p99 regression. |
One change to the app, needed only for the staging run.
The rate limits in security.rs are constants that only dev mode turns off.
Dev mode needs a loopback bind (src/dev.rs), so it can’t serve a load
generator on another machine. Every request from that generator then
shares one address and hits REGISTER_PER_IP after 20 sign-ups. Add
SKRONGLE_RATE_LIMITS=off. It logs a warning at startup, and
deploy-aws.sh refuses to write it unless AWS_STACK_NAME contains
load. The change is about 20 lines.
3. The virtual learner
The traffic model comes from the first report’s section 1, so test numbers and report numbers mean the same thing.
A learner (75 % of arrivals) is one k6 virtual user each:
GET /(landing),GET /register, then submit the form withres.submitForm()and the addressload-<run>-<vu>-<iter>@example.test. This exercises argon2, the outbox row and the session. Follow the Terms step if the app asks.POST /courses/open/<id>for a random built-in course. 20 % of learners also pick a theme, spread evenly over all of them.- Repeat for ~20 minutes:
GETa lesson or/practiceevery ~30 s. Read the question ids fromdata-qidandPOST /api/answerwith{question_id, answer}every ~6 s (wrong answers cost the same). Every ~40 answers, finish a round. - Some learners come back: 30 % of them sign in again with
POST /login(the second argon2 path).
A visitor (25 % of arrivals) loads three pages (/, a static asset,
/register) and leaves.
One setting scales the load: -e LEARNERS=500 is the reports’ peak hour.
Sleeps are randomised (±50 %), so 500 learners don’t answer in lockstep.
4. The profiles
Each profile targets bottlenecks the reports found, and fails if the bottleneck is still there. The thresholds are a starting point. Tune them after the first green run on each target.
| Profile | Shape | Finds | Fails if |
|---|---|---|---|
smoke | 10 learners, 1 min | the script itself is broken | any error |
peak | ramp to 500 learners over 5 min, hold 15 min | #2 blocking threads; slow home page | answer p99 > 200 ms, page p99 > 500 ms, errors > 0.1 % |
spike | 50 learners, then 1,500 within 60 s plus 10 sign-ups a second for 2 min, then back to 50 | argon2 queue with no backpressure; the 256-connection limit; time to recover | errors > 1 %; p99 still > 2× its pre-spike value 2 min after the spike |
signups | 1,000 sign-ups over 10 min, nothing else | #1 mail ceiling; the e-mail lookups that scan users | outbox rows ≠ sign-ups (failed at 100 before #579; capped mail now waits), sign-up p99 > 1 s |
memory | 50 learners, each wearing every theme on every course; 20 open user-made courses | #3/#4 themed copies and the course cache | RSS > the unit’s MemoryHigh, or any restart |
history | 50 learners seeded with 14,000 answers each, browsing home | #4 home page reads all history | home p99 > 300 ms |
ai (Mac and Docker only) | 20 Generate presses within a minute, SKRONGLE_LLM_DELAY_MS=60000 | #5 two AI slots | any refused start (today: 18 refused) |
soak | 150 learners for 2 h | leaks, growing caches, hourly housekeeping stalls | RSS grows > 20 % after the first 15 min; p99 spikes on the hour |
smoke, peak, spike and signups cover the viral day. The rest target
specific findings and run when the code they test changes.
Seeding. The history profile needs learners with weeks of answers.
Writing 14,000 answers each through the API would take an hour, so the
script seeds them straight into SQLite before the run (copy quiz_history
rows from a template learner with shifted timestamps). It must stop the
app first and start it with SKRONGLE_DEV_FORCE=1, because dev mode refuses
a database with other accounts (#388).
5. What to watch on the server
The client sees only latency and status codes. While k6 runs, the script also samples the server every 5 s, and it checks a few facts at the end.
| Signal | How (Mac / Docker / AWS) | Points at |
|---|---|---|
| RSS of the app | ps -o rss / docker stats / systemctl show learnmap -p MemoryCurrent | #3/#4 memory |
| Thread count | ps -M / /proc/<pid>/status | tutor threads vs TasksMax |
| CPU, credits | top / docker stats / CloudWatch CPUUtilization, CPUCreditBalance | burstable CPU (AWS #3) |
| Journal lines | grep -c for not queued, rate limit hit, killed, Too many courses | #1, #6, OOM, #5 |
| Outbox rows | sqlite3 learn.db "select count(*) from mail_outbox where created_at >= <start>" | #1: must equal sign-ups |
| Restarts | systemctl show -p NRestarts | any crash |
| Request times at the proxy | Caddy’s access log, once AWS report #8 adds it | which path got slow |
Reading a failure. Each bottleneck has a recognisable shape:
| Shape | Likely cause |
|---|---|
| Every endpoint’s p99 jumps together during sign-up bursts, CPU not full | blocking pool taken by argon2 (#2) |
Home p99 grows with the history seed, answers stay flat | day_streak / stats_since (#4) |
| p99 climbs slowly and then a burst of 502s | memory limit and restart (#3) |
| Flat ~5 % CPU and everything slow, on AWS only | CPU credits ran out (AWS #3) |
| Latency fine, sign-ups fine, outbox count short | mail ceiling (#1) |
| Connect errors while latency is fine | 256-connection limit (SKRONGLE_MAX_CONNECTIONS) |
6. Where to run it
1. The Mac: every time a hot path changes
scripts/load-test.sh --where mac --profile peak
./.dev-start --release --reset --background with the simulated LLM, then
k6 on the same machine. Dev mode turns rate limits off and keeps all mail
in the dev mailbox. Dev mode also has no hourly mail ceiling unless one is
set, so the script sets SKRONGLE_MAIL_PER_HOUR to the production default.
Without it, signups would pass falsely.
The numbers are flattering (a fast laptop, no TLS). What counts is the
comparison with load/baseline.json, so a change that makes the home page
3× slower fails here, before review. Run it when a PR touches src/db.rs,
src/auth.rs, routes/auth.rs, routes/pages.rs, routes/quiz.rs,
src/mail.rs, src/courses.rs or src/ai.rs. The QA playbook
(docs/qa.md) can name it for those paths.
2. Docker on Colima: monthly, and before a release that changes limits
scripts/load-test.sh --where docker --size aws --profile spike
The runners already run arm64 Linux in Colima, the same architecture and OS family as the Graviton instance. The script starts the release binary in a container sized like the systemd unit:
--size | Docker flags | Imitates |
|---|---|---|
aws | --cpus 2 --memory 192m --pids-limit 64 | learnmap.service on a t4g.nano |
aws-throttled | --cpus 0.1 --memory 192m | a t4g.nano out of CPU credits |
pi | --cpus 0.25 --memory 64m --pids-limit 32 | the Pi’s unit (roughly; it can’t imitate an SD card) |
Dev mode answers only requests addressed to localhost, so k6 runs in a
second container that shares the app’s network namespace
(docker run --network container:skrongle-load grafana/k6 …). This run
gives the Linux memory numbers both reports list as not measured.
Run it outside CI hours, one at a time. The runners share the Mac’s disk, and a full disk takes them all offline.
3. A throwaway AWS stack: before an expected spike
AWS_STACK_NAME=skrongle-load AWS_INSTANCE_TYPE=t4g.nano scripts/deploy-aws.sh
scripts/load-test.sh --where https://<ip>.sslip.io --profile spike
scripts/deploy-aws.sh --destroy # with AWS_STACK_NAME=skrongle-load
This uses the same template, Caddy and unit as production, with
SKRONGLE_RATE_LIMITS=off and no mail provider (the log chain, so no real
e-mail is sent and the ceiling still applies). Run k6 from a second
instance in the same region, a c7g.large for an hour (~$0.07), so the
laptop’s uplink doesn’t become the bottleneck. Never point it at
production. Nothing stops the run there, and 1,500 fake accounts would
stay in the real database.
Only this run sees real TLS cost, Caddy’s compression, CPU credits being
spent, and the instance’s memory with the OS and Caddy in it. Watch
CPUCreditBalance in CloudWatch during spike. Run peak once on
t4g.nano and once on t4g.small to decide whether the event needs the
bigger box. A run costs well under a dollar (instance-hours plus a few GB of
traffic within the free allowance).
7. Why not on every pull request
CI runs on eight Docker runners on one laptop, alongside the e2e shards. Load numbers taken there move with whatever else is running, so a threshold either flaps or is too loose to catch anything. Instead:
- PRs that touch a hot path get the Mac run (an author’s two-line note in the PR body: profile, p99 before and after).
- A weekly scheduled
peak+signupsin Docker, at night when the runners are idle, posts its result file to an issue and fails loudly against the baseline. - The AWS run is on the checklist for any planned launch.
8. Getting started, in order
brew install k6. Writeload/learner.jswith thesmokeandpeakprofiles, and run them against./.dev-start --release. (~½ day)scripts/load-test.shfor the Mac: start, run, sample RSS, grep the journal, count outbox rows, write the result file, compare with the baseline. (~½ day)- Add
spikeandsignups.signupsshould fail today on the mail ceiling, and that failure proves the harness works. (~2 h) - Docker mode with
--size, and the first Linux memory numbers. (~½ day) SKRONGLE_RATE_LIMITS=off, the guard indeploy-aws.sh, and one staging run before the next launch. (~½ day)memory,history,aiandsoakas the matching bottlenecks get fixed. Each one turns a fix into a regression test.