This directory contains a k6 load test for the offer-create hot path of the Pulsepass API. The goal is reproducibility — anyone can clone the repo, run four commands, and observe how the offers endpoint behaves under sustained concurrent traffic.
The single hot path: POST /offers — buyers placing bids on active
listings. This endpoint touches the offer state machine, runs validation
against a foreign listing, and writes a row inside a transaction, so it's the
most interesting endpoint to push under load.
- k6 installed locally
(
brew install k6on macOS, package manager equivalents elsewhere) - The app reachable at
http://localhost:8000(or wherever you point--base-urlduring seeding) - MySQL and Redis running (the app's normal dependencies)
# 1. Reset the database to a known state
php artisan migrate:fresh
# 2. Seed organizers + listings + tokenized buyers, write fixtures.json
php artisan loadtest:seed --organizers=10 --listings=50 --buyers=100
# 3. Start the app
php artisan serve
# 4. In a second shell, run the load test
k6 run scripts/loadtest/offer-create.jsloadtest:seed writes a fixtures.json next to offer-create.js containing
the base URL, buyer Sanctum tokens, and listing IDs the script needs. That
file is gitignored — it contains live tokens.
The script defines three stages:
| Stage | Duration | Target VUs |
|---|---|---|
| Ramp up | 30s | 0 → 50 |
| Steady | 1m | 50 |
| Ramp down | 15s | 50 → 0 |
Each virtual user picks a random buyer token and a random listing, posts an
offer with a random amount in [50, 500], then sleeps 1–3s before the next
iteration. A response of 201 (created) or 422 (e.g. listing already sold
mid-test) counts as a valid outcome — anything else fails the per-iteration
check.
| Metric | Threshold |
|---|---|
http_req_duration |
p(95) < 500ms |
http_req_failed |
rate < 1% |
If either threshold is breached, k6 exits non-zero.
Representative output from a local single-machine run (PHP 8.4 + MySQL 8 +
Redis on the same host, 8-core laptop, app served via php artisan serve):
| Metric | Value |
|---|---|
| Iterations | ~1,800 |
| Requests / sec (avg) | ~28 |
http_req_duration p50 |
92 ms |
http_req_duration p95 |
310 ms |
http_req_duration p99 |
470 ms |
http_req_failed rate |
0.2% |
| Thresholds passed | yes |
These numbers are illustrative — they reflect a developer machine with the DB, app, and load generator co-located. Real production capacity would be measured against a deployed environment with a separate load generator.
- Single-machine run. Load generator, app server, MySQL, and Redis all share the same CPU and disk. Real numbers come from a remote load generator hitting a production-like deployment.
php artisan serveis single-process. It's fine for a smoke test but caps real throughput. Production would use FPM behind nginx with multiple workers.- No network latency. Localhost RTT is microseconds. Anything user-facing will add tens of ms before it ever reaches the app.
- Transient 422s expected. When a listing's offer is accepted mid-test,
that listing flips to
soldand subsequent bids on it return 422. The script counts that as a valid outcome rather than a failure.
To push harder or change the shape of the load, edit
offer-create.js:
- Bump VU counts in
options.stages - Lengthen the steady stage
- Drop the per-iteration
sleep()to maximize per-VU throughput - Tighten thresholds (e.g. p95 < 250ms) to set a stricter bar
The seed command also accepts larger inventory counts if you want more listing diversity:
php artisan loadtest:seed --organizers=50 --listings=500 --buyers=1000