Skip to content

k6 load testing tutorial: authorisation and capture under load with k6 v2.3.0

A k6 v2.3.0 load test for a payment application programming interface (API) drives authorisations at a controlled arrival rate with the ramping-arrival-rate executor, gives each virtual user iteration its own idempotency key, and fails the run on 95th and 99th percentile latency per operation, error rate, check rate, and dropped iterations.

k6 v2.3.0 and the docs this tutorial follows

Grafana Labs published k6 v2.3.0 on 21 September 2026. The release adds a --scenario flag that runs selected named scenarios "without editing the script", and a --once flag for single runs. Every command and output block in this post comes from the Windows amd64 build of v2.3.0 (k6.exe v2.3.0 (commit/e088784614, go1.26.8, windows/amd64)), downloaded from that release page on 22 September 2026 and checked against the published SHA-256 checksum.

The k6 docs are versioned; the pages cited here are the v2.3.x set, read on 22 September 2026. The ramping-arrival-rate page says the executor "starts these iterations as long as the test has enough allocated VUs", with stages and preAllocatedVUs required, timeUnit defaulting to "1s", and maxVUs defaulting to preAllocatedVUs when unset.

Why arrival rate matters for authorisation and capture

The open and closed models page states that "in the closed model, VU iterations start only when the last iteration finishes", while the arrival-rate executors use the open model. Checkout traffic follows the open model: each shopper presses "Pay" without waiting for other shoppers' authorisations to finish. A closed-model test sends fewer requests as latency grows, so the slowdown also lowers the load, and the percentiles stay flat.

For an acquirer, a slow authorisation turns into money in two directions. A client timeout means a lost sale and a lost take rate on it. A client retry without a working idempotency key means a second authorisation hold, and a second capture if the retry path captures too. The acquiring segment page traces both flows. The replay semantics themselves (altered bodies, parallel replays, key expiry) are in our payment idempotency testing guide; this post only proves that replays still hold at peak rate.

The script: scenarios, keys, checks and thresholds

The script has two scenarios. auth_capture ramps from 5 to 40 authorisations started per second over 30 seconds, holds 40 per second for one minute, and drains over 15 seconds; each iteration authorises and then captures the full amount. client_retries overlaps the hold and sends 2 iterations per second, each posting the same authorisation twice with one key.

Idempotency keys combine a run ID from setup(), the scenario name, exec.vu.idInTest, and exec.vu.iterationInScenario. The key is unique across virtual users (VUs), stable inside one iteration, and different on every rerun, so a second run never collides with keys the API still stores from the first. The payload carries a synthetic token and no cardholder data; our test environment rules keep real card data out of load environments.

Every request carries an op tag, so thresholds apply per operation. An aggregate http_req_duration mixes captures averaging 46 ms with authorisations averaging 78 ms in the run below and hides an authorisation regression inside the average. The threshold values are ours for this demo; on a client project they come from the agreed load model.

JavaScript
import http from 'k6/http';
import { check } from 'k6';
import exec from 'k6/execution';

const BASE_URL = __ENV.BASE_URL || 'http://localhost:18080';
const PEAK_RATE = Number(__ENV.PEAK_RATE || 40); // authorisations started per second at peak

export const options = {
  scenarios: {
    auth_capture: {
      executor: 'ramping-arrival-rate',
      exec: 'authorizeAndCapture',
      startRate: 5,
      timeUnit: '1s',
      preAllocatedVUs: 20,
      maxVUs: 100,
      stages: [
        { target: PEAK_RATE, duration: '30s' }, // ramp to the peak rate
        { target: PEAK_RATE, duration: '1m' }, // hold the peak
        { target: 0, duration: '15s' }, // drain
      ],
    },
    client_retries: {
      executor: 'constant-arrival-rate',
      exec: 'retryAuthorization',
      rate: 2,
      timeUnit: '1s',
      duration: '1m',
      startTime: '30s', // overlaps the peak hold
      preAllocatedVUs: 5,
      maxVUs: 10,
    },
  },
  thresholds: {
    'http_req_duration{op:authorize}': ['p(95)<250', 'p(99)<400'],
    'http_req_duration{op:capture}': ['p(95)<150', 'p(99)<300'],
    http_req_failed: ['rate<0.001'],
    checks: ['rate>0.999'],
    'checks{scenario:client_retries}': ['rate==1'],
    dropped_iterations: ['count<1'],
    'http_reqs{scenario:auth_capture,op:capture}': ['count>0'], // empty submetrics pass
  },
};

export function setup() {
  return { runId: `${Date.now()}` };
}

// One key per VU and iteration: unique across VUs, stable if the same iteration retries.
function idempotencyKey(runId, step) {
  return `${runId}-${exec.scenario.name}-vu${exec.vu.idInTest}-it${exec.vu.iterationInScenario}-${step}`;
}

function post(path, body, key, op) {
  return http.post(`${BASE_URL}${path}`, JSON.stringify(body), {
    headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
    tags: { op, name: op },
  });
}

const authBody = () => ({
  amount: 1999,
  currency: 'EUR',
  payment_method: 'tok_synthetic_visa', // synthetic token, no card data
  capture_method: 'manual',
});

export function authorizeAndCapture(data) {
  const auth = post('/v1/authorizations', authBody(), idempotencyKey(data.runId, 'auth'), 'authorize');
  const authorized = check(auth, {
    'authorize: 201': (r) => r.status === 201,
    'authorize: status authorized': (r) => r.json('status') === 'authorized',
  });
  if (!authorized) return;

  const cap = post(
    `/v1/authorizations/${auth.json('id')}/captures`,
    { amount: 1999 },
    idempotencyKey(data.runId, 'capture'),
    'capture',
  );
  check(cap, {
    'capture: 201': (r) => r.status === 201,
    'capture: amount equals authorised amount': (r) => r.json('amount') === 1999,
  });
}

export function retryAuthorization(data) {
  const key = idempotencyKey(data.runId, 'auth');
  const body = authBody();
  const first = post('/v1/authorizations', body, key, 'authorize');
  const replay = post('/v1/authorizations', body, key, 'authorize_replay');
  check(replay, {
    'replay: 200, not a new 201': (r) => first.status === 201 && r.status === 200,
    'replay: same authorization id': (r) => Boolean(r.json('id')) && r.json('id') === first.json('id'),
  });
}

Run it against a local mock

To reproduce the output without a sandbox account, run this Node.js 22 mock of an authorisation and capture API. It adds 30 to 110 ms of latency to authorisations and 15 to 60 ms to captures, plus 200 ms on 1% of requests, and stores responses by idempotency key.

JavaScript
// mock.mjs: node mock.mjs  (listens on port 18080)
import http from 'node:http';
import { randomUUID } from 'node:crypto';
const byKey = new Map(); const payments = new Map();
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const lat = (lo, hi) => lo + Math.random() * (hi - lo) + (Math.random() < 0.01 ? 200 : 0);
function send(res, code, body) { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); }
http.createServer((req, res) => {
  let raw = ''; req.on('data', (c) => (raw += c)); req.on('end', async () => {
    const key = req.headers['idempotency-key'];
    if (req.method === 'POST' && req.url === '/v1/authorizations') {
      await wait(lat(30, 110));
      const b = JSON.parse(raw);
      if (!key) return send(res, 400, { error: 'missing_idempotency_key' });
      const prev = byKey.get(key);
      if (prev) { if (prev.body !== raw) return send(res, 422, { error: 'idempotency_key_reused' }); return send(res, 200, prev.resp); }
      const p = { id: 'auth_' + randomUUID(), status: 'authorized', amount: b.amount, currency: b.currency, captured: 0 };
      payments.set(p.id, p); byKey.set(key, { body: raw, resp: p }); return send(res, 201, p);
    }
    const m = req.url.match(/^\/v1\/authorizations\/([^/]+)\/captures$/);
    if (req.method === 'POST' && m) {
      await wait(lat(15, 60));
      const p = payments.get(m[1]); if (!p) return send(res, 404, { error: 'not_found' });
      const prev = byKey.get(key); if (prev) return send(res, 200, prev.resp);
      const b = JSON.parse(raw); if (p.captured + b.amount > p.amount) return send(res, 409, { error: 'over_capture' });
      p.captured += b.amount; const c = { id: 'cap_' + randomUUID(), authorization: p.id, status: 'captured', amount: b.amount };
      byKey.set(key, { body: raw, resp: c }); return send(res, 201, c);
    }
    send(res, 404, { error: 'not_found' });
  });
}).listen(Number(process.env.PORT || 18080), () => console.log('mock up'));
SHELL
k6 run -q -e BASE_URL=http://localhost:18080 payments-load.js

This is the end-of-test summary from that run on one Windows 11 laptop on 22 September 2026, exit code 0. The latencies describe the mock only. The options reference lists compact as the default summary mode; add --summary-mode=full for more detail.

Output
  █ THRESHOLDS

    checks
    ✓ 'rate>0.999' rate=100.00%

      {scenario:client_retries}
      ✓ 'rate==1' rate=100.00%

    dropped_iterations
    ✓ 'count<1' count=0

    http_req_duration{op:authorize}
    ✓ 'p(95)<250' p(95)=115.02ms
    ✓ 'p(99)<400' p(99)=129.74ms

    http_req_duration{op:capture}
    ✓ 'p(95)<150' p(95)=65.23ms
    ✓ 'p(99)<300' p(99)=221.64ms

    http_req_failed
    ✓ 'rate<0.001' rate=0.00%

    http_reqs{scenario:auth_capture,op:capture}
    ✓ 'count>0' count=3374


  █ TOTAL RESULTS

    checks_total.......: 13738   130.836819/s
    checks_succeeded...: 100.00% 13738 out of 13738
    checks_failed......: 0.00%   0 out of 13738

    ✓ authorize: 201
    ✓ authorize: status authorized
    ✓ capture: 201
    ✓ capture: amount equals authorised amount
    ✓ replay: 200, not a new 201
    ✓ replay: same authorization id

    HTTP
    http_req_duration........................: avg=62.56ms  min=14.45ms med=56.24ms  max=316.21ms p(90)=100.35ms p(95)=109.66ms
      { expected_response:true }.............: avg=62.56ms  min=14.45ms med=56.24ms  max=316.21ms p(90)=100.35ms p(95)=109.66ms
      { op:authorize }.......................: avg=77.78ms  min=29.36ms med=75.23ms  max=316.21ms p(90)=108.57ms p(95)=115.02ms
      { op:capture }.........................: avg=46.3ms   min=14.45ms med=46.15ms  max=276.12ms p(90)=62.53ms  p(95)=65.23ms
    http_req_failed..........................: 0.00%  0 out of 6990
    http_reqs................................: 6990   66.570779/s
      { scenario:auth_capture,op:capture }...: 3374   32.13302/s

    EXECUTION
    dropped_iterations.......................: 0      0/s
    iteration_duration.......................: avg=125.41ms min=49.47ms med=122.43ms max=396.27ms p(90)=161ms    p(95)=173.34ms
    iterations...............................: 3495   33.28539/s
    vus......................................: 0      min=0         max=8
    vus_max..................................: 25     min=25        max=25

    NETWORK
    data_received............................: 2.2 MB 21 kB/s
    data_sent................................: 2.0 MB 19 kB/s

Three failures that passed the latency thresholds in our runs

Missing load reads as good latency. The dropped iterations page says that with the arrival-rate executors "iterations drop if there are no free VUs". We reran the script with -e PEAK_RATE=1200 and the same maxVUs: 100. Every latency threshold passed, and only the dropped_iterations threshold failed the run with exit code 99:

Output
level=warning msg="Insufficient VUs, reached 100 active VUs and cannot initialize more" executor=ramping-arrival-rate scenario=auth_capture

    dropped_iterations
    ✗ 'count<1' count=24467

    http_req_duration{op:authorize}
    ✓ 'p(95)<250' p(95)=107.64ms
    ✓ 'p(99)<400' p(99)=122.73ms

level=error msg="thresholds on metrics 'dropped_iterations' have been crossed"

Without that threshold, the run reports green while 24,467 planned iterations never started. Raise maxVUs or add load generators until dropped_iterations stays at 0 at the target rate.

An empty submetric passes. In an early run where the requests hit the wrong local port and received error responses, the capture thresholds printed p(95)=0s and passed, because no capture request existed. The count>0 guard on http_reqs{scenario:auth_capture,op:capture} fails that run. Scoping the guard to its scenario matters with the v2.3.0 --scenario flag: k6 run -q --scenario client_retries warned that it "skipped thresholds for excluded scenarios" and exited 0, while an unscoped guard failed the same selective run.

A replay check that compares two missing IDs. Our first version compared r.json('id') of both responses. In the same run, both were undefined, and the check passed 121 times. The check now requires a 201 on the first request and a non-empty ID.

After the run: count the money

The summary shows 3,374 successful captures. The ledger for this run's key prefix must show the same number of captures at 19.99 EUR, and one authorisation per client_retries key. Our ledger reconciliation guide has the queries. In our performance testing service, the latency and throughput report puts these thresholds next to post-run ledger totals, and the load model sets PEAK_RATE and the threshold values.

Scope your testing

Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.

Scope your testing