Benchmarking Webhook Throughput with k6

“Our webhook endpoint handles 2,000 requests per second” is almost always a claim about a load generator, not about a receiver. The number that matters operationally is narrower: how many signature-verified, enqueued, acknowledged deliveries per second the receiver sustains before its handler latency departs from flat — and, separately, how many its workers can actually drain. This guide builds that measurement on top of load testing webhook endpoints using a stepped k6 benchmark, and it is the companion to simulating webhook traffic spikes: a spike test asks “does it survive a sudden herd”, a benchmark asks “where exactly is the ceiling, and which component owns it”.

The distinction that trips people up is admission versus completion. A receiver that returns 202 Accepted in four milliseconds and pushes to a queue will happily admit 5,000 requests per second while its workers drain 400. Every HTTP metric says the endpoint is healthy; the backlog says the system is minutes from collapse. A benchmark that measures only http_req_duration will report the wrong number by an order of magnitude, so the first step is not writing the k6 script — it is making the receiver tell you what is happening behind the 202.

Prerequisites

Step 1: Expose the four numbers the benchmark depends on

Four quantities describe a webhook receiver under load, and only two of them are visible to k6 by default. Offered rate (what you asked for) and achieved rate (what actually left) come free from the arrival-rate executor. Handler duration and queue depth live inside the receiver, so send them back on the response where the load generator can record them per request.

Metric Measured where What it tells you Failure signature
Offered arrival rate k6 executor config The independent variable of the experiment dropped_iterations above zero means k6 could not offer it
Achieved request rate k6 http_reqs rate Whether the system accepted what you offered Achieved falls below offered while VUs are free
Handler duration p95 Receiver middleware header Verify-and-enqueue cost, excluding network Departs from flat well before errors appear
Queue depth Receiver header or /benchmark/health Whether admission outruns the drain Rises monotonically and never returns to baseline
Error rate by status k6 http_req_failed Where the receiver starts shedding 429 or 503 concentrated at one step

A small FastAPI middleware is enough on the receiver side:

import time

from fastapi import FastAPI, Request

app = FastAPI()
queue: "asyncio.Queue" = ...          # the receiver's existing bounded work queue


@app.middleware("http")
async def expose_benchmark_signals(request: Request, call_next):
    started = time.perf_counter()
    response = await call_next(request)
    elapsed_ms = (time.perf_counter() - started) * 1000
    # Handler-only cost: excludes client network time and k6's own overhead.
    response.headers["X-Handler-Duration-Ms"] = f"{elapsed_ms:.3f}"
    response.headers["X-Queue-Depth"] = str(queue.qsize())
    return response


@app.get("/benchmark/health")
async def health() -> dict[str, int | float]:
    return {"queue_depth": queue.qsize(), "queue_max": queue.maxsize}

Emitting these as response headers rather than scraping Prometheus separately is deliberate: k6 records them against the exact request that observed them, so a p95 handler duration and the queue depth at that moment end up in the same summary, aligned in time, with no clock-correlation work afterwards.

Where each benchmark metric is measured The generator offers a rate, the receiver reports handler duration, the queue reports depth, and workers set the drain rate; admission and drain capacity are separate spans. Four measurement points, not one number p95 handler duration queue depth growth k6 generator signed POSTs receiver verify + enqueue queue bounded workers downstream offered arrival rate worker drain rate admission capacity drain capacity
Admission capacity and drain capacity are two different ceilings, and the smaller one is the number you can actually quote.

Step 2: Generate a valid signature for every request

An unsigned benchmark measures a code path production never runs. Reproduce the signing scheme exactly, including the canonical string the receiver reconstructs — a mismatch here means you measure the rejection path, which is much cheaper and will flatter your numbers enormously.

// bench.js
import http from "k6/http";
import crypto from "k6/crypto";
import { check } from "k6";
import { Trend, Gauge, Counter } from "k6/metrics";

const SECRET = __ENV.WEBHOOK_SECRET;
const HOST = __ENV.HOST;
const PATH = __ENV.PATH_ || "/webhooks/orders";

export const handlerMs = new Trend("handler_duration_ms", true);
export const queueDepth = new Gauge("receiver_queue_depth");
export const accepted = new Counter("accepted_events");

function signedRequest() {
  const ts = Math.floor(Date.now() / 1000).toString();
  const body = JSON.stringify({
    id: `evt_${__VU}_${__ITER}_${ts}`,   // unique: defeats dedup and caching
    type: "order.created.v1",
    created_at: ts,
    data: { amount: 1000 + (__ITER % 9000), currency: "USD" },
  });
  // Canonical string must match the receiver byte for byte.
  const mac = crypto.hmac("sha256", SECRET, `${ts}.${body}`, "base64");
  return {
    body,
    headers: {
      "Content-Type": "application/json",
      "X-Webhook-Timestamp": ts,
      "X-Webhook-Signature": `v1=${mac}`,
    },
  };
}

export default function () {
  const { body, headers } = signedRequest();
  const res = http.post(`${HOST}${PATH}`, body, { headers });

  const handler = res.headers["X-Handler-Duration-Ms"];
  if (handler !== undefined) {
    handlerMs.add(parseFloat(handler));
  }
  const depth = res.headers["X-Queue-Depth"];
  if (depth !== undefined) {
    queueDepth.add(parseInt(depth, 10));
  }
  const ok = check(res, {
    "accepted": (r) => r.status === 202 || r.status === 200,
    "signature accepted": (r) => r.status !== 401,
  });
  if (ok) {
    accepted.add(1);
  }
}

Two properties are worth guarding. The event ID varies per iteration, so consumer-side deduplication and any HTTP cache in front of the receiver cannot short-circuit the work — a fixed payload can make a receiver look twice as fast as it is. And the signature accepted check exists to catch the silent failure where every request 401s and the benchmark cheerfully reports 8,000 req/s of rejections.

One benchmarked delivery, end to end A k6 virtual user signs a payload, posts it, the receiver verifies and enqueues, returns 202, and a worker dequeues the event some time later. k6 virtual user receiver queue worker sign HMAC POST + signature verify + parse enqueue 202 Accepted dequeued later http_req_duration covers only the POST-to-202 span
Everything to the right of the 202 is invisible to HTTP metrics, which is exactly why queue depth has to ride back on the response.

Step 3: Step the arrival rate and hold each level

Use an open-model executor. ramping-arrival-rate offers a fixed number of iterations per second regardless of how slow responses become; a closed model (constant-vus) throttles itself as latency rises and will report a plateau that is really just your VU count divided by response time.

Hold each step long enough for queues to reach steady state. Sixty seconds is a reasonable floor; if your worker pool has a warm-up (JIT, connection pools, a cold page cache), the first step should be discarded entirely.

const STEP = Number(__ENV.STEP || 200);   // rps increment per level
const HOLD = __ENV.HOLD || "60s";
const RAMP = "10s";

export const options = {
  discardResponseBodies: false,   // needed: we read response headers
  scenarios: {
    staircase: {
      executor: "ramping-arrival-rate",
      startRate: STEP,
      timeUnit: "1s",
      preAllocatedVUs: 800,
      maxVUs: 4000,
      stages: [
        { target: STEP * 1, duration: HOLD },
        { target: STEP * 2, duration: RAMP }, { target: STEP * 2, duration: HOLD },
        { target: STEP * 3, duration: RAMP }, { target: STEP * 3, duration: HOLD },
        { target: STEP * 4, duration: RAMP }, { target: STEP * 4, duration: HOLD },
        { target: STEP * 5, duration: RAMP }, { target: STEP * 5, duration: HOLD },
        { target: STEP * 6, duration: RAMP }, { target: STEP * 6, duration: HOLD },
      ],
    },
  },
  thresholds: {
    http_req_failed: ["rate<0.005"],
    handler_duration_ms: ["p(95)<250"],
    dropped_iterations: ["count<1"],   // generator must keep up or the run is void
  },
};

export function handleSummary(data) {
  const m = data.metrics;
  const line =
    `achieved ${m.http_reqs.values.rate.toFixed(1)} req/s | ` +
    `handler p95 ${m.handler_duration_ms.values["p(95)"].toFixed(1)} ms | ` +
    `queue ${m.receiver_queue_depth.values.value} | ` +
    `errors ${(m.http_req_failed.values.rate * 100).toFixed(2)}%\n`;
  return { "summary.json": JSON.stringify(data), stdout: line };
}

Run one level per invocation so each step produces its own summary file, which makes the analysis in Step 4 trivial and lets you abandon the run as soon as a step clearly fails:

for n in 1 2 3 4 5 6; do
  RATE=$((200 * n))
  STEP=$RATE HOLD=60s WEBHOOK_SECRET=$SECRET HOST=https://staging.example.com \
    k6 run --summary-export=/dev/null bench.js || true
  mv summary.json "step-${RATE}.json"
done

The dropped_iterations threshold is the guard that keeps the experiment honest. Any nonzero value means k6 wanted to issue requests and could not — the generator, not the receiver, was the constraint, and every number from that step is meaningless.

Step 4: Locate the knee and attribute it to a layer

The knee is where the latency curve stops being flat. Before it, added load is absorbed by idle capacity and latency barely moves; after it, each additional request per second waits behind the previous ones and latency rises superlinearly. Quote the last step before the knee as your capacity, not the highest rate that returned 2xx.

import json
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class StepResult:
    offered: float
    achieved: float
    handler_p95_ms: float
    error_rate: float
    queue_depth: float


def load_step(path: Path, offered: float) -> StepResult:
    metrics = json.loads(path.read_text())["metrics"]
    return StepResult(
        offered=offered,
        achieved=metrics["http_reqs"]["values"]["rate"],
        handler_p95_ms=metrics["handler_duration_ms"]["values"]["p(95)"],
        error_rate=metrics["http_req_failed"]["values"]["rate"],
        queue_depth=metrics["receiver_queue_depth"]["values"]["value"],
    )


def find_knee(
    steps: list[StepResult],
    latency_multiple: float = 2.0,
    admission_floor: float = 0.95,
    error_budget: float = 0.005,
) -> StepResult:
    """Last step that still behaved; the next one broke one of three rules."""
    baseline = steps[0].handler_p95_ms
    for previous, current in zip(steps, steps[1:]):
        if current.handler_p95_ms > baseline * latency_multiple:
            return previous
        if current.achieved < current.offered * admission_floor:
            return previous
        if current.error_rate > error_budget:
            return previous
    return steps[-1]


def attribute(step: StepResult, queue_max: int) -> str:
    """Which layer owns the ceiling at this step?"""
    if step.queue_depth > queue_max * 0.5:
        return "downstream: admission outran the worker drain rate"
    if step.handler_p95_ms > 100 and step.queue_depth < queue_max * 0.1:
        return "receiver: verify-and-enqueue path is the bottleneck"
    return "neither saturated: raise the offered rate and rerun"


if __name__ == "__main__":
    rates = [200, 400, 600, 800, 1000, 1200]
    steps = [load_step(Path(f"step-{r}.json"), float(r)) for r in rates]
    knee = find_knee(steps)
    print(f"sustainable admission rate: {knee.achieved:.0f} req/s")
    print(f"handler p95 at that rate:   {knee.handler_p95_ms:.1f} ms")
    print(f"limiting layer:             {attribute(steps[-1], queue_max=10_000)}")

Attribution is the half of the exercise that produces an action item. A flat queue depth with rising handler latency means the receiver itself is the constraint — signature verification, JSON parsing, TLS termination, or a synchronous call that should not be on the request path. A flat handler latency with a monotonically rising queue means the receiver is fine and the workers are the ceiling; the fix is worker concurrency or downstream capacity, and until then the correct behaviour under overload is to shed rather than accept, which is what applying backpressure to webhook consumers covers. Publishing the resulting number as a documented limit lets senders configure token bucket rate limiting against a real figure instead of guessing.

Finding the knee of the curve Handler p95 latency stays flat as offered rate rises, then climbs sharply past the knee while achieved throughput flattens out. p95 latency knee at ~780 req/s p95 handler latency achieved throughput stable saturated 200 400 600 800 1000 offered arrival rate (req/s)
Capacity is the last step before latency lifts off the floor, not the highest rate that still returned a 2xx.

Verification and testing

A benchmark you cannot reproduce is an anecdote. Before quoting a number, prove the measurement itself is sound.

First, verify the receiver is actually verifying. Run a deliberately mis-signed request and confirm it is rejected — if it is accepted, every previous run measured a path with no cryptography in it:

curl -s -o /dev/null -w '%{http_code}\n' \
  -X POST https://staging.example.com/webhooks/orders \
  -H 'Content-Type: application/json' \
  -H 'X-Webhook-Timestamp: 1721898000' \
  -H 'X-Webhook-Signature: v1=deadbeef' \
  -d '{"id":"evt_bogus","type":"order.created.v1"}'
# Expect: 401

Second, prove the generator was not the bottleneck at the knee. Re-run the knee step from two hosts at half the rate each; if the combined result is materially better than the single-host result, the single host was the limit and the real knee is higher:

# Host A and host B simultaneously, each at half the knee rate.
STEP=400 HOLD=60s k6 run bench.js   # on each of two generator hosts

Third, assert the run is repeatable. Three consecutive runs of the knee step should agree within about 10% on handler p95; wider variance means something uncontrolled is in the loop — a noisy neighbour, an autoscaler moving underneath you, or a database cache that was warm on one run and cold on the next.

def test_knee_is_reproducible():
    runs = [load_step(Path(f"knee-run-{i}.json"), offered=800.0) for i in (1, 2, 3)]
    p95s = [r.handler_p95_ms for r in runs]
    spread = (max(p95s) - min(p95s)) / min(p95s)
    assert spread < 0.10, f"handler p95 varies by {spread:.0%} across runs"
    assert all(r.error_rate < 0.005 for r in runs)

Finally, confirm the queue drained. A step that ends with a queue depth well above where it started did not run at a sustainable rate, no matter what its latency percentiles say — extend the hold and watch whether depth stabilises or climbs without bound.

Failure modes and gotchas

Frequently Asked Questions

Can the benchmark run against production instead of a staging replica?

Only with a dedicated path: a route or tenant whose events are recognised as synthetic and dropped before any downstream side effect, and a signing secret separate from the live one. Without that you are writing test orders into real data while consuming the worker pool your customers depend on. A staging environment shaped like production is almost always the cheaper answer, and its number is trustworthy as long as worker counts and pool sizes match.

What should the handler p95 threshold actually be set to?

Derive it from the sender's delivery timeout rather than picking a round number. If the provider abandons an attempt at five seconds, your budget is that timeout minus network round-trip and TLS setup, and the threshold should sit far enough below it that the knee becomes visible long before deliveries start failing. A value in the low hundreds of milliseconds is common precisely because it leaves room for the tail, not because any particular figure is meaningful on its own.

How much does payload size change the answer?

A great deal, because both HMAC computation and JSON parsing scale with body length, and a staircase built on a 2 KB synthetic event says nothing about a 200 KB payload from a bulk provider. Take the median and 99th-percentile body sizes from your delivery log and run the staircase at both. Two ceilings differing by a factor of three is an ordinary result, and the one worth publishing is the one matching the traffic you actually receive.

Does connection reuse in the generator flatter the result?

It can. Virtual users hold connections open, so TLS handshakes get amortised in a way a fleet of sender hosts opening fresh connections may not. If your terminator is a plausible bottleneck, run one comparison pass with connection reuse disabled and read the difference as the handshake cost your ingress absorbs. Do not make that the default run, or the headline number becomes a measurement of TLS setup rather than of the receiver.

The queue is a broker rather than an in-process one — how do we report depth on the response?

Never call the broker on the request path: a depth query per request adds latency to the very quantity you are measuring, and at a few thousand requests per second it may get you throttled. Poll depth from a background task once or twice a second and have the middleware emit the cached value, which is precise enough for a signal that only matters as a trend across a sixty-second step. Record the sampling interval with the results so nobody reads the gauge as instantaneous.

How often should this be re-run, and what does the number get used for afterwards?

Run the full staircase after an infrastructure or dependency change and on a slow cadence otherwise; it is far too long to gate every merge. What belongs in the release path is a single fixed step somewhat below the measured knee, held for a minute, with handler p95 and error rate as thresholds — a regression check rather than a discovery run. When that fixed step starts failing, it is time to spend the hours on a full staircase again.