Skip to content

Load Testing

AnvilBase ships a reusable k6 load-testing suite under load/ that exercises every data-plane surface — REST, Auth, Storage, Queues, Cache — under four canonical load shapes: smoke → throughput → stress → soak.

The suite is parameterised entirely by environment variables, so the same scripts run against any deployment without edits: you point BASE_URL at a dedicated test stack, supply the project id and keys, and pick a scenario.

Where to run this — read first

Never point this suite at a production deployment, and never run k6 on the same host as the system under test. Load testing exists to push a system to its limits — doing that to prod takes prod down, and doing it from the same box means the load generator and AnvilBase fight over the same CPU/RAM/NIC, so you measure the host, not AnvilBase. The numbers are then meaningless.

Run k6 from a separate machine (ideally a separate host/region) against a dedicated, prod-sized test deployment. The test plan below is a checklist you approve before the real run.

The suite at a glance

load/
├── lib/
│ ├── config.js # env → BASE_URL/PROJECT_ID/keys + URL & header helpers
│ ├── scenarios.js # smoke|throughput|stress|soak → k6 options + thresholds
│ └── checks.js # shared check() wrappers + per-surface Trend/Rate metrics
├── rest-read.js # GET a table (anon / authenticated)
├── rest-write.js # POST/PATCH a table (service_role)
├── auth.js # signup + password-grant token
├── storage.js # upload + download an object (service_role)
├── queues.js # send + read + ack a message (service_role)
├── mixed.js # weighted blended workload across surfaces
└── run.sh # thin wrapper: ./run.sh <surface> <scenario>

Each script targets the native data-plane shape (/v1/<subsystem>/<project_id>/…) and authenticates with the Supabase-compatible apikey + Authorization: Bearer headers (see Data-plane API).

Scenarios

The scenario is selected with SCENARIO=smoke|throughput|stress|soak. Every script shares one definition (load/lib/scenarios.js), so the methodology is consistent across surfaces.

Scenariok6 executorWhat it answersDefault shape
smokeconstant-vus”Does this endpoint work under k6 at all?” — a CI-safe sanity gate.1 VU, 30s
throughputramping-arrival-rate”What sustained RPS can I hold, and at what latency?” — an open model that holds a fixed arrival rate regardless of how the system responds.ramp to RATE RPS, hold 3m
stressramping-arrival-rate (stepped up)“Where’s the breaking point?” — ramps the arrival rate past the expected ceiling until thresholds break. Thresholds do not abort, so the run reaches the failure zone.step to RATE peak, hold 2m
soakconstant-arrival-rate”Does it degrade or leak over time?” — moderate steady load for hours surfaces memory leaks, pool exhaustion, and latency drift a short run hides.RATE RPS, 2h

Why an arrival-rate (open) model for throughput/stress? A closed VU model throttles itself when the system slows (each VU waits for its response before sending the next), which hides saturation. An open model keeps offering the target rate, so a slowing system shows up as rising latency and a growing error rate — exactly the signal you want when finding a ceiling.

Tunables

Override any default without editing scripts:

VarEffect
VUSworker pool size / VU count
DURATIONhold duration for the active scenario
RATEtarget requests/second for arrival-rate executors
P95_MS / P99_MSlatency thresholds (default 500 / 1000 ms)
ERR_RATEmax error rate threshold (default 0.01 = 1%)

Configuration

VarRequiredExample
BASE_URLyeshttp://localhost:39001 or https://test.anvil.example
PROJECT_IDyesp_abc123
ANON_KEYfor anon surfacesanvilbase_anon_…
SERVICE_ROLE_KEYfor write/storage/queue surfacesanvilbase_service_role_…
AUTH_EMAIL / AUTH_PASSWORDauth token-grant onlya seeded test user
TABLE BUCKET QUEUE CACHE_KEYnosurface targets (sensible defaults)

On a local compose stack the control plane maps host 39001 → container 3001, so BASE_URL=http://localhost:39001. Behind Traefik on a test box it’s the public HTTPS origin.

Seed schema

The rest-* and mixed scripts read/write a table (default load_test). Create it on the test project first:

create table if not exists public.load_test (
id bigint generated always as identity primary key,
label text,
created_at timestamptz not null default now()
);
-- For anon reads, disable RLS on this throwaway test table or add a permissive
-- SELECT policy. This table lives on a THROWAWAY project — never your real data.

storage.js and queues.js create their bucket / queue automatically (idempotent).

Running

Via the wrapper (load/run.sh <surface> <scenario>):

Terminal window
BASE_URL=https://test.anvil.example \
PROJECT_ID=p_abc123 \
ANON_KEY=anvilbase_anon_… \
SERVICE_ROLE_KEY=anvilbase_service_role_… \
./load/run.sh rest-read throughput
# stream raw samples to a file for later analysis
... ./load/run.sh mixed stress -- --out json=mixed-stress.json

Or call k6 directly:

Terminal window
k6 run -e SCENARIO=throughput \
-e BASE_URL=… -e PROJECT_ID=… -e ANON_KEY=… \
load/rest-read.js

Reading the results

k6 prints a summary at the end of every run. The numbers that matter:

MetricWhat it tells you
http_req_duration p(50)/p(95)/p(99)latency. p95 is the headline SLO; p99 catches tail blow-ups.
http_req_failederror rate (non-2xx / transport errors). The first thing to break under stress.
http_reqs / iterations (per second)achieved throughput. Compare against the target RATE: a gap means the system couldn’t keep up.
vus / vus_maxhow many workers were needed to sustain the rate — climbing VUs at a flat rate signals the system is slowing.
dropped_iterations(arrival-rate executors) requests k6 couldn’t even start because the VU pool was exhausted — raise VUS/maxVUs or you’ve found a real ceiling.
Per-surface Trends (rest_read_latency, auth_token_latency, …)latency broken down by surface, so a blended mixed run shows which surface is the bottleneck.

A / next to each threshold shows pass/fail. In stress runs, thresholds are expected to fail at the top stages — the rate/VU level at which http_req_failed crosses your ERR_RATE or http_req_duration p95 crosses P95_MS is the knee.

Thresholds

Defaults (tune to your box):

http_req_failed: rate < 0.01 (< 1% errors)
http_req_duration: p(95) < 500ms, p(99) < 1000ms
<surface>_latency: p(95) < per-surface budget (e.g. auth p95 < 1500ms)

These are deliberately conservative starting points. Calibrate them against your dedicated box’s baseline (a smoke run) before trusting pass/fail.

Automated provisioning rig

For a reproducible, disposable rig you don’t have to wire by hand, load/infra/ provisions a System-Under-Test box + a separate k6 generator on a private network (OpenTofu/Terraform), deploys AnvilBase from source, seeds a throwaway test project, and drives the matrix below — on Hetzner (hcloud) or Infomaniak (OpenStack), so you can run the same experiment head-to-head and compare (e.g. local NVMe vs networked Ceph storage). It honours the rules on this page: the generator is always a separate box, the target is always a throwaway project, and tofu destroy tears it all down. See load/infra/README.md.

Dedicated-server test plan

A meaningful load test is a controlled experiment, not a one-off command. Treat the steps below as a checklist the operator approves before the real run.

1. Provision a dedicated test server

  • Stand up a separate server sized to your intended production target. Document the assumptions so results are reproducible, e.g. a baseline of 8 vCPU / 16 GB RAM / NVMe SSD with ≥ 3k IOPS / 1 Gbps network. Storage throughput and Postgres connection limits matter as much as CPU.
  • This host runs only AnvilBase — no k6, no other tenants. It is disposable: you will push it to failure.
  • Record the exact spec (instance type, kernel, disk class) alongside the run.

2. Deploy AnvilBase + seed a test project

  • Deploy via docker compose on the dedicated host (see Docker Compose). Use the same image tags and config as production so the test reflects reality.
  • Create a throwaway test project and capture its PROJECT_ID, anon and service_role keys.
  • Apply the seed schema. For anon reads, disable RLS on the throwaway load_test table (or add a permissive SELECT policy).
  • Run a smoke scenario against each surface to confirm wiring and capture a baseline latency before adding load.

3. Run k6 from a separate host

  • Run k6 on a different machine — ideally a different host or region — so the generator never competes with the system under test. A small dedicated runner is enough; if you saturate the runner, scale it out or use k6 distributed mode, not a bigger box on the SUT.
  • Confirm the network path between runner and SUT mirrors how real clients reach it (through Traefik / TLS), so you measure the real ingress.

4. Ramp methodology

Run in this order, one surface at a time, then mixed:

  1. smoke — sanity + baseline latency (1 VU).
  2. throughput — hold a target RATE you believe prod needs; confirm p95 and error rate stay within SLO. This validates the expected load.
  3. stress — step the rate up past that target until http_req_failed or the p95 threshold breaks. The level just before the break is the knee — your sustainable ceiling.
  4. soak — hold a moderate rate (well under the knee) for hours; watch for latency drift, growing memory, or pool exhaustion.

5. Metrics to capture

On the k6 side (per surface and for the blended mixed run):

  • Achieved RPS vs target, and the RPS at which p95 < your SLO holds.
  • The rate/VU level where the error rate begins to climb (onset, not just the final number).
  • p95 / p99 latency curves as the rate increases.

On the server side (scrape /metrics, see Monitoring, plus host metrics):

  • CPU and memory saturation — which resource tops out first?
  • Postgres connections / pool exhaustion — the REST→SQL per-project pool, Supavisor, dropped_iterations on the k6 side, and any db_project_pools gauge plateauing.
  • Disk IOPS / latency (storage + Postgres WAL) and network throughput.

6. Interpreting the breaking point

  • The knee is where added load stops buying throughput: RPS flattens (or falls) while latency and error rate climb. That flat-RPS-rising-latency inflection is your sustainable ceiling — quote it as “N RPS at p95 < X ms”.
  • Cross-reference the knee with server metrics to name the bottleneck resource (CPU? Postgres connections? disk IO?). That’s what you scale next — see Scaling.
  • Re-run after each change to confirm the ceiling moved, and keep the runner, scenario, and server spec constant so results stay comparable.

The suite generates real traffic and, for write surfaces, real rows/objects/messages on the test project. Reset or recreate the throwaway project between full runs so a prior run’s data doesn’t skew the next. Never run write or stress scenarios against a project you care about.