running-load-tests-with-k6
Single source of truth for designing, running, and interpreting k6 load tests against lenne.tech fullstack APIs. Defines installation paths (brew, docker, npm), the three canonical scenarios (smoke / load / soak), endpoint discovery from the generated SDK, realistic Better-Auth login flows, threshold defaults for ~10 concurrent users (p95 < 500ms, error rate < 1%, http_req_failed < 1%), result interpretation, and the optimisation ladder when the system fails (DB indices, query rewrites, caching, connection pool sizing, rate-limit relaxation, payload trimming). Activates whenever an agent or command needs to validate that the API is stable for ~10 concurrent users performing many actions in short time, or to detect performance regressions via k6. Currently used by /lt-dev:production-ready, lt-dev:production-readiness-orchestrator, and lt-dev:performance-reviewer. NOT for Lighthouse frontend performance (use a11y-reviewer). NOT for unit performance assertions (use the test runner directly).
What this skill does
# Running Load Tests with k6
This skill is the **single source of truth** for k6 load tests in the lenne.tech stack. It exists so every consumer (command, agent, reviewer) produces comparable scenarios with the same thresholds, the same auth flow, and the same optimisation ladder.
> **Goal:** Prove the API is stable for **10 concurrent users performing many actions in a short time window** — and surface concrete optimisation steps when it is not.
## When to Use This Skill
| Caller | Phase | Trigger |
|--------|-------|---------|
| `/lt-dev:production-ready` | Phase 3 | Mandatory load-test gate before sign-off |
| `lt-dev:production-readiness-orchestrator` | Phase 3 | Runs the scenarios, owns the optimisation loop |
| `lt-dev:performance-reviewer` | Phase 7 (optional) | Regression check vs baseline JSON |
| Manual user invocation | Ad-hoc | Reproducing a customer-reported slowness |
## Step 1 — Ensure k6 is available
Detect first, install only when missing.
```bash
command -v k6 >/dev/null 2>&1 && k6 version || echo "missing"
```
Install paths in order of preference:
| Platform | Command |
|----------|---------|
| macOS | `brew install k6` |
| Linux (deb) | `sudo gpg -k; sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69; echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list; sudo apt-get update; sudo apt-get install k6` |
| Any (no install) | `docker run --rm -i --network host grafana/k6:latest run -` (pipes script via stdin) |
| CI / no sudo | Download static binary from `https://github.com/grafana/k6/releases/latest` and place on `$PATH` |
Never call `npm install -g k6` — that package is a placeholder and not the real binary.
If k6 cannot be installed at all, the consumer must classify Phase 3 as **BLOCKED — k6 unavailable** and stop. Never silently skip the load test.
## Step 2 — Resolve the API base URL and warm-up the server
The base URL must come from a deterministic source — pick the first that resolves:
1. `$API_BASE_URL` environment variable
2. `lt.config.json` → `ports.api` → `http://localhost:<port>`
3. `~/.lenneTech/ports.json` registry entry for the current project slug
4. `projects/api/src/config.env.ts` default (`PORT`)
5. Fallback `http://localhost:3000`
The API must already be running when k6 starts. If the consumer detects no listening server, start it via the `managing-dev-servers` skill (`lt dev up` preferred, otherwise `pnpm run dev` with `run_in_background: true`) and wait for `GET /meta` (or `/health`) to return 200 before calling k6.
## Step 3 — Discover the endpoint surface
Pull the realistic endpoint list from the generated SDK so the load test exercises actual code paths, not invented URLs.
```bash
# Backend (NestJS) — Swagger contract
curl -s "${API_BASE_URL}/api-json" | jq -r '.paths | keys[]'
# Frontend SDK — generated REST client
test -f projects/app/app/api/sdk.gen.ts && grep -E "url: '/" projects/app/app/api/sdk.gen.ts | sort -u
```
Pick a representative subset for the scenario (typically 5–8 endpoints). Always include:
- One auth endpoint (`POST /auth/sign-in`)
- One read-heavy list endpoint (e.g. `GET /users` with paging)
- One read-heavy detail endpoint (`GET /users/:id` or similar)
- One write endpoint (POST/PATCH) that exercises a realistic mutation
- One filter/search endpoint if available
## Step 4 — Authenticate via Better-Auth (the realistic path)
The lt-stack ships Better-Auth in cookie mode. Don't bypass it — load tests that hit `/auth/sign-in` and propagate the session cookie find real bugs (DB pool exhaustion under login storm, refresh-token contention, etc).
Test users come from the seed script. Either reuse the deterministic seeded credentials or call the seed script before the run:
```bash
test -f projects/api/scripts/seed.ts && pnpm --filter @projects/api run seed
```
In the k6 script, log in once per VU in `setup()` and pass the cookie via shared state to keep auth out of the per-iteration timing. Default test users follow the seed convention (`[email protected] … [email protected]`, password `Password!23`) — verify against the actual seed script before relying on this.
## Step 5 — Three canonical scenarios
Use exactly these three scenarios. The default for `/lt-dev:production-ready` Phase 3 is **load**; **smoke** is the pre-check; **soak** runs only when the user passes `--include-soak`.
### Smoke (1 VU, 1 minute) — sanity check before the real run
```js
// k6/scenarios/smoke.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 1,
duration: '1m',
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500'],
},
};
```
### Load (10 VUs ramp + steady, ~5 minutes) — the production-ready gate
```js
// k6/scenarios/load.js
export const options = {
stages: [
{ duration: '30s', target: 10 }, // ramp up
{ duration: '4m', target: 10 }, // steady state
{ duration: '30s', target: 0 }, // ramp down
],
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500', 'p(99)<1500'],
checks: ['rate>0.99'],
},
};
```
### Soak (10 VUs, 30 minutes) — long-running stability check
```js
// k6/scenarios/soak.js
export const options = {
vus: 10,
duration: '30m',
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500'],
},
};
```
The **soak** scenario detects memory leaks, connection-pool exhaustion, and slow-growing GC pressure that load misses. Run it as a long-running background process via `run_in_background: true` and re-poll its summary file at the end.
## Step 6 — A complete script template
Place all k6 scripts under `tests/load/` (create the directory if missing). One scenario per file plus a shared `helpers.js`.
```js
// tests/load/helpers.js
import http from 'k6/http';
import { check } from 'k6';
const BASE_URL = __ENV.API_BASE_URL || 'http://localhost:3000';
export function login(email, password) {
const res = http.post(`${BASE_URL}/auth/sign-in/email`, JSON.stringify({ email, password }), {
headers: { 'Content-Type': 'application/json' },
});
check(res, { 'login 200': (r) => r.status === 200 });
return res.cookies; // propagate as Cookie header in subsequent calls
}
export function authedHeaders(cookies) {
const cookieHeader = Object.entries(cookies)
.flatMap(([name, arr]) => arr.map((c) => `${name}=${c.value}`))
.join('; ');
return { 'Content-Type': 'application/json', Cookie: cookieHeader };
}
export { BASE_URL };
```
```js
// tests/load/load.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { BASE_URL, login, authedHeaders } from './helpers.js';
export const options = {
stages: [
{ duration: '30s', target: 10 },
{ duration: '4m', target: 10 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500', 'p(99)<1500'],
checks: ['rate>0.99'],
},
};
export function setup() {
const cookies = login(`user-${(__VU % 10) + 1}@example.com`, 'Password!23');
return { cookies };
}
export default function (data) {
const headers = authedHeaders(data.cookies);
group('list users', () => {
const r = http.get(`${BASE_URL}/users?take=20`, { headers });
check(r, { 'list 200': (x) => x.status === 200 });
});
group('user detail', () => {
const r = http.get(`${BASE_URL}/users/me`, { headers });
check(r, { 'me 200': (x) => x.status === 200 });
});
group('write action', () => {
const r = http.patch(
`${BASE_URL}/users/me`,
JSON.stringify({ lastSeen: new Date().toISOString() }),
{ headers },
);
check(r, { 'patch 200/204': (x) => x.status === 200 || x.status === 204 });
});
sRelated in Web Dev
generating-lwc-components
IncludedLightning Web Components with PICKLES methodology and 165-point scoring. Use this skill when the user creates or edits LWC components, builds wire service patterns, or writes Jest tests for LWC. TRIGGER when: user creates/edits LWC components, touches lwc/**/*.js, .html, .css, .js-meta.xml files, or asks about wire service, SLDS, or Jest LWC tests. DO NOT TRIGGER when: Apex classes (use generating-apex), Aura components, or Visualforce.
tanstack-query
IncludedManage server state in React with TanStack Query v5. Set up queries with useQuery, mutations with useMutation, configure QueryClient caching strategies, implement optimistic updates, and handle infinite scroll with useInfiniteQuery. Use when: setting up data fetching in React projects, migrating from v4 to v5, or fixing object syntax required errors, query callbacks removed issues, cacheTime renamed to gcTime, isPending vs isLoading confusion, keepPreviousData removed problems.
document-processor-api
IncludedProcess documents with Nutrient DWS. Use when the user wants to generate PDFs from HTML or URLs, convert Office/images/PDFs, assemble or split packets, OCR scans, extract text/tables/key-value pairs, redact PII, watermark, sign, fill forms, optimize PDFs, or produce compliance outputs like PDF/A or PDF/UA. Triggers include convert to PDF, merge these PDFs, OCR this scan, extract tables, redact PII, sign this PDF, make this PDF/A, or linearize for web delivery.
nutrient-document-processing
IncludedProcess documents with Nutrient DWS. Use when the user wants to generate PDFs from HTML or URLs, convert Office/images/PDFs, assemble or split packets, OCR scans, extract text/tables/key-value pairs, redact PII, watermark, sign, fill forms, optimize PDFs, or produce compliance outputs like PDF/A or PDF/UA. Triggers include convert to PDF, merge these PDFs, OCR this scan, extract tables, redact PII, sign this PDF, make this PDF/A, or linearize for web delivery.
tanstack-query
IncludedManage server state in React with TanStack Query v5. Covers useMutationState, simplified optimistic updates, throwOnError, network mode (offline/PWA), and infiniteQueryOptions. Use when setting up data fetching, fixing v4→v5 migration errors (object syntax, gcTime, isPending, keepPreviousData), or debugging SSR/hydration issues with streaming server components.
accelint-nextjs-best-practices
IncludedNext.js performance optimization and best practices. Use when writing Next.js code (App Router or Pages Router); implementing Server Components, Server Actions, or API routes; optimizing RSC serialization, data fetching, or server-side rendering; reviewing Next.js code for performance issues; fixing authentication in Server Actions; or implementing Suspense boundaries, parallel data fetching, or request deduplication.