---
title: "HTTP/2 and HTTP/3 Under Load: What Changes in Latency, Errors, and TLS Costs"
description: "HTTP/2 vs HTTP/3 load testing: multiplexing, QUIC, TLS costs, and honest k6 comparisons—MDN evolution notes and Grafana k6 HTTP metrics."
publishDate: "2026-08-31"
draft: false
keyword: "http3 load testing"
intent: "MOFU"
cta: "Use Performate to turn this playbook into runnable k6 scenarios, thresholds, and shareable reports without losing days to glue code."
tags: ["k6","load-testing","api-performance","http3"]
---

HTTP/3 shaved 20ms in a lab slide—then production tails worsened because UDP buffers, cert chains, and CDN PoPs were not held constant. Protocol benchmarks become ops archaeology unless you freeze TLS policy, payload sizes, and geography.

HTTP/2 multiplexes many requests over one TCP connection; **HTTP/3** moves transport to QUIC (UDP-based) and typically improves **head-of-line blocking** at the cost of different CPU and handshake profiles ([Evolution of HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Guides/Basics_of_HTTP/Evolution_of_HTTP), [RFC 9114](https://www.rfc-editor.org/rfc/rfc9114) QUIC overview for HTTP/3). For API teams, the question is not which RFC logo wins but **which stack your edge and clients actually negotiate** under production cert and cipher policy. This guide shows how to compare H2 and H3 honestly in k6, which failure modes differ by protocol, and when to escalate tails vs error rates.

Pair experiments with [CDN cache behavior](/en/blog/cdn-cache-headers-load-testing) and [geo-distributed testing](/en/blog/geo-distributed-load-testing) when edges terminate stacks per region.

## Why protocol changes move tails—not just averages

Under load, differences show up in:

- **Connection setup** — QUIC handshakes vs TCP+TLS reuse; cold starts exaggerate first-byte latency.
- **Lossy paths** — HTTP/3 can reduce head-of-line blocking on mobile networks; LAN labs may show no win.
- **Middleboxes** — UDP filtering causes resets that HTTP/2 never saw on the same path.
- **Server CPU** — encryption and QUIC user-space stacks shift cost from kernel wait to CPU burn.

Averages hide those effects. Compare `p95`/`p99` with identical RPS ([p95 vs p99](/en/blog/p95-vs-p99-latency)) and tag runs `proto:h2` vs `proto:h3` ([tags and groups](https://k6.io/docs/using-k6/tags-and-groups/)).

**k6 does not replace packet capture.** Use load tests to quantify user-visible latency and error budgets under sustained RPS; use pcaps or edge logs when you need to prove middlebox UDP drops or TLS renegotiation storms. The script above is for regression gates, not RFC conformance certification.

### When a protocol “win” is a configuration drift

Changing ALPN advertisement without updating kernel `udp` buffer limits or `max_open_files` on generators produces fake regressions ([fine-tuning OS](https://k6.io/docs/misc/fine-tuning-os/)). Document infra changes beside protocol toggles so reports stay comparable.

## Practical k6 implementation: tagged protocol scenarios

k6 records [`http_req_duration`](https://k6.io/docs/using-k6/metrics/) and related timings—compare runs only when certificate chain, TLS version policy, payload sizes, and CDN PoP behavior are frozen.

**Example script (illustrative—not production-ready).** Endpoints must expose H2 and H3 on your infra; fictional hosts.

**What this example demonstrates:**

- **Parallel scenarios** hitting H2-only and H3-capable base URLs (or host aliases) at the same RPS.
- **Protocol tags** for segmented thresholds.
- **Identical payload and headers** so compression and auth match.
- **Failure and tail thresholds** per protocol, not one aggregate line.

```javascript
import http from 'k6/http';
import { check, sleep } from 'k6';

const H2_BASE = __ENV.H2_BASE || 'https://h2.staging.example.com';
const H3_BASE = __ENV.H3_BASE || 'https://h3.staging.example.com';
const BODY = JSON.stringify({ query: 'latency-probe', size: 'medium' });

export const options = {
  scenarios: {
    proto_h2: {
      executor: 'constant-arrival-rate',
      rate: Number(__ENV.TARGET_RPS || 80),
      timeUnit: '1s',
      duration: '8m',
      preAllocatedVUs: 30,
      maxVUs: 120,
      tags: { proto: 'h2' },
      exec: 'hitH2',
    },
    proto_h3: {
      executor: 'constant-arrival-rate',
      rate: Number(__ENV.TARGET_RPS || 80),
      timeUnit: '1s',
      duration: '8m',
      preAllocatedVUs: 30,
      maxVUs: 120,
      tags: { proto: 'h3' },
      exec: 'hitH3',
    },
  },
  thresholds: {
    'http_req_duration{proto:h2}': ['p(95)<420', 'p(99)<750'],
    'http_req_duration{proto:h3}': ['p(95)<400', 'p(99)<720'],
    'http_req_failed{proto:h2}': ['rate<0.005'],
    'http_req_failed{proto:h3}': ['rate<0.008'],
  },
};

export function hitH2() {
  const res = http.post(`${H2_BASE}/api/search`, BODY, {
    headers: { 'Content-Type': 'application/json' },
    tags: { proto: 'h2', route: 'search' },
  });
  check(res, { 'h2 2xx': (r) => r.status >= 200 && r.status < 300 });
  sleep(0.1);
}

export function hitH3() {
  const res = http.post(`${H3_BASE}/api/search`, BODY, {
    headers: { 'Content-Type': 'application/json' },
    tags: { proto: 'h3', route: 'search' },
  });
  check(res, { 'h3 2xx': (r) => r.status >= 200 && r.status < 300 });
  sleep(0.1);
}
```

**Patterns that work**

- **Baseline the real client mix** (HTTP/1.1 upgrades, H2 everywhere, or H3 advertising) before synthetic toggles.
- **Ramp identical RPS/VU models**; compare tails, not averages alone.
- **Log negotiation** from ingress for the test window; align tags with observed splits.
- **Run mesh on/off pairs** in Kubernetes when sidecars terminate TLS differently ([Kubernetes microservices](/en/blog/kubernetes-microservice-load-testing)).

**Anti-patterns to avoid**

- Benchmarking different payload sizes “because H3 is newer.”
- Ignoring generator UDP tuning while blaming the app for early resets.
- Declaring victory on LAN staging when mobile loss paths drive prod tails.

**Pro tip (example command):**

```bash
k6 run http-proto-compare.js --summary-trend-stats="p(95),p(99)" -e TARGET_RPS=80
```

**What this command demonstrates:** side-by-side percentile trends for `proto:h2` and `proto:h3` tags in one summary.

## Decision framework: when to invest in H3 under load

| Signal | Often protocol-related when | Recommended action |
|:---|:---|:---|
| Rising tail latency, stable CPU | Buffering / QUIC loss on path | Capture pcaps; test from lossy link profile |
| Early connection resets | Middleboxes mishandling UDP | Firewall lab; fallback policy |
| Shorter lived sessions | Aggressive idle timeouts on new path | Align CDN/proxy keep-alive with H2 baseline |
| H3 wins on `p99` mobile only | Lossy radio paths | Geo + device mix tests ([geo-distributed](/en/blog/geo-distributed-load-testing)) |
| H3 higher CPU, similar tails | Encryption/stack cost | Capacity plan CPU headroom |

**Stay on H2** if staging shows no tail win and ops cost of UDP path debugging is high.

**Pilot H3** when ingress logs show material H3 share and mobile tails dominate SLO breaches.

**Block release** when `http_req_failed{proto:h3}` exceeds budget even if `p95` looks better—errors trump averages.

## Stakeholder narrative

Explain trade-offs using [throughput vs latency](/en/blog/throughput-vs-latency-stakeholders): HTTP/3 may lower tails for lossy networks while shifting server CPU—budget both. Attach protocol tags to reports ([how to read load test reports](/en/blog/how-to-read-load-test-reports)) so leadership sees the same splits engineering used.

## Pre-release checklist

- [ ] Document TLS cert chain, cipher policy, and ALPN settings for H2 vs H3 runs.
- [ ] Freeze payload size, compression, and auth headers across protocol scenarios.
- [ ] Record generator OS tuning (`udp` buffers, `max_open_files`) for H3 runs.
- [ ] Compare `p95`/`p99` and `http_req_failed` per `proto` tag, not global aggregates.
- [ ] Archive ingress negotiation stats for the test window beside k6 exports.

## How Performate simplifies protocol A/B runs

Toggle endpoints without rewriting entire collections each release.

**Example: compare H2 and H3 endpoints from one collection**

1. **Import** search/checkout requests once; duplicate hosts for `h2.` and `h3.` aliases. *Problem solved:* identical bodies and headers across protocols.
2. **Create two scenarios** with the same arrival rate and tags `proto:h2` / `proto:h3`. *Problem solved:* honest RPS matching without script forks.
3. **Run and open comparison view** filtered by protocol tag. *Problem solved:* tails visible per stack in one export.
4. **Attach infra notes** (CDN PoP, cert change) in the report footer for the deprecation meeting.
5. **Export k6** for CI regression when H3 share crosses your adoption threshold.
6. **Share with SRE** alongside [debug failing load test runbook](/en/blog/debug-failing-load-test-runbook) if resets spike mid-test.

## Closing takeaway

Protocol comparisons are **controlled experiments**, not slogan slides. Freeze TLS, payloads, and geography; tag every request; judge tails and errors before averages.

Run your next H2/H3 pair at identical RPS—and record whether `p99` or `http_req_failed` moved first. That answer tells you if the stack is ready, not just faster on paper.

[Try Performate free](https://performate.app) | [Book a demo](/demo) | [k6 HTTP requests](https://k6.io/docs/using-k6/http-requests/)
