Load Testing HTTP/2 Applications with h2load on Ubuntu

Written by: Bagus Facsi Aginsa
Published at: 23 May 2026


You upgraded your web server to HTTP/2, enabled TLS, and turned on multiplexing. Response times feel snappier in the browser. But did the server actually get faster under load? And are you actually testing it the right way?

Most load testing tools — Apache Benchmark (ab), wrk, basic curl loops — speak HTTP/1.1. When you point them at an HTTP/2 server, they either fall back to HTTP/1.1 automatically or refuse the connection entirely. You end up measuring your server’s HTTP/1.1 compatibility layer, not its HTTP/2 performance at all.

h2load is the dedicated HTTP/2 and HTTP/3 load testing tool from the nghttp2 project. It speaks HTTP/2 natively, respects stream multiplexing, supports multiple concurrent streams per connection, and gives you metrics that are specific to the HTTP/2 protocol — things like stream concurrency and frame-level timing.

In this tutorial you will install h2load on Ubuntu, run your first load test against an HTTP/2 server, understand every line of its output, tune the key parameters that make HTTP/2 testing different from HTTP/1.1 testing, and benchmark a real-world scenario with POST requests. You will also do a side-by-side comparison between HTTP/2 and HTTP/1.1 so you can actually see what the protocol upgrade is worth.


How h2load Is Different from ab or curl

Before touching commands, it helps to understand what makes HTTP/2 testing fundamentally different.

HTTP/1.1 is serial per connection: one request at a time per TCP connection. To simulate concurrency, tools like ab open many parallel connections. Fifty concurrent connections means fifty simultaneous TCP handshakes, fifty TLS negotiations, and fifty independent request-response cycles.

HTTP/2 is multiplexed: many streams share a single TCP connection. A browser loading a page with 30 assets sends all 30 requests over one connection as parallel streams. The server interleaves responses back over the same connection. Connection overhead (TCP handshake, TLS negotiation) happens once per connection, not once per request.

h2load models this correctly. You configure two independent dimensions:

  • -c (connections) — how many TCP connections to open
  • -m (max concurrent streams) — how many parallel streams per connection

With -c 10 -m 20, h2load opens 10 connections and sends up to 20 streams concurrently over each, for up to 200 in-flight requests at once. Testing an HTTP/2 server with only -c 50 -m 1 (50 connections, 1 stream each) misses the point entirely: you are not exercising multiplexing, you are just doing HTTP/1.1-style testing over an HTTP/2 transport.


Prerequisites

  • Ubuntu 20.04, 22.04, or 24.04
  • A non-root user with sudo privileges
  • A running HTTPS server with HTTP/2 enabled — your own nginx or Apache with a valid TLS certificate, or a public HTTPS endpoint for initial experimentation
  • Basic command-line familiarity

h2load requires TLS by default for HTTP/2 targets (this mirrors how browsers work — HTTP/2 over cleartext is technically allowed but almost never used in practice). If your server only has a self-signed certificate, you will need the --insecure flag, which is covered in step 2.


Step 1: Install h2load

h2load ships as part of the nghttp2-client package on Ubuntu. No third-party repositories needed:

sudo apt update
sudo apt install nghttp2-client -y

Verify the installation:

h2load --version

Expected output (version number varies):

h2load nghttp2/1.51.0

The nghttp2 package also includes nghttp (a command-line HTTP/2 client, similar to curl) and nghttpd (a minimal HTTP/2 server useful for testing). You get the whole toolkit with one install.


Step 2: Run Your First Load Test

The minimal h2load invocation needs a URL, a request count (-n), and a concurrency level (-c):

h2load -n 1000 -c 10 -m 10 https://example.com/

What this does:

  • -n 1000 — send a total of 1,000 requests
  • -c 10 — open 10 parallel connections
  • -m 10 — allow up to 10 concurrent streams per connection

h2load distributes the 1,000 requests across the 10 connections, sending up to 10 streams per connection simultaneously.

If your server uses a self-signed certificate:

h2load --insecure -n 1000 -c 10 -m 10 https://192.168.1.50/

--insecure skips certificate verification. Use it only in staging environments — never against production systems where you need certificate validation to catch misconfigurations.

You should see a progress bar while the test runs, then a full results block. We will read that output in detail in the next step.


Step 3: Understand the Output

Here is a complete sample output from a test against a local nginx server:

finished in 3.21s, 311.53 req/s, 1.24MB/s
requests: 1000 total, 1000 started, 1000 done, 1000 succeeded, 0 failed, 0 errored, 0 timeout
status codes: 1000 2xx, 0 3xx, 0 4xx, 0 5xx
traffic: 3.99MB (4183552) total, 42.97KB (44002) headers (space savings 62.89%), 3.94MB (4128768) data
                     min         max         mean         sd        +/-sd
time for request:   1.25ms    213.04ms     30.14ms     27.89ms    77.84%
time for connect:    982µs      3.72ms      2.11ms    734.38µs    60.00%
time to 1st byte:  14.31ms     18.92ms     16.64ms      1.47ms    70.00%
req/s           :      28.67      35.84      31.15       2.06    70.00%

Line by line:

finished in 3.21s, 311.53 req/s, 1.24MB/s — the test ran for about 3 seconds and averaged 311 requests per second with a data throughput of 1.24 MB/s. This is your headline throughput number.

requests: 1000 total, 1000 succeeded, 0 failed — all requests completed without error. Watch the failed and errored counts. failed means the server returned a 4xx or 5xx. errored means the connection itself broke before a response was received.

status codes: 1000 2xx — all responses were successful. If you see 5xx here under load, the server is overloaded or misconfigured.

headers (space savings 62.89%) — this is HTTP/2-specific. HTTP/2 uses HPACK header compression. The 62.89% savings means h2load sent roughly 2.7x less header data than it would have over HTTP/1.1. On endpoints with large request headers (authentication tokens, cookies), header compression is a significant real-world win.

time for request: min 1.25ms, max 213.04ms, mean 30.14ms — request latency distribution. The gap between mean (30ms) and max (213ms) shows you have outliers. A standard deviation of 27.89ms for a mean of 30ms is high — about 77% of requests complete within one SD of the mean (the +/-sd column), but the tail is long. Investigate server-side bottlenecks if you see high SD.

time for connect: mean 2.11ms — TCP connection establishment time. With multiplexing, each connection is established once and reused for many streams, so this overhead is amortized across all -m streams.

time to 1st byte: mean 16.64ms — how long from opening the connection until the first byte of the first response arrives. This captures server processing time for the first request in each connection.

req/s per connection: mean 31.15 — average throughput per connection. With 10 connections total, the aggregate is roughly 311 req/s, which matches the headline figure.


Step 4: Tune Concurrency and Streams

The two parameters that matter most for HTTP/2 load testing are -c (connections) and -m (streams per connection). Changing them reveals different characteristics of your server.

Test 1: Many connections, 1 stream each (HTTP/1.1-like behavior)

h2load -n 1000 -c 50 -m 1 https://example.com/

This opens 50 connections but uses only one stream at a time per connection. You are still using HTTP/2 transport, but you are not exercising multiplexing. Use this as a baseline.

Test 2: Few connections, many streams (true HTTP/2 multiplexing)

h2load -n 1000 -c 5 -m 50 https://example.com/

Five connections, up to 50 concurrent streams each. The same 1,000 requests are served through 5 TCP connections. Total connection overhead (TLS handshakes) drops from 50 to 5. If your server is optimized for HTTP/2, throughput should be similar or better than Test 1 while connection-setup overhead drops.

Test 3: Scale up for stress testing

h2load -n 10000 -c 20 -m 30 https://example.com/

10,000 total requests across 20 connections with 30 streams each gives you up to 600 in-flight requests at once. This is a realistic stress test for a production-grade service. Watch your server’s CPU and memory while this runs.

Adding a warm-up period with -W and -D

h2load -n 5000 -c 10 -m 10 -W 3 https://example.com/

-W 3 adds a 3-second warm-up period where requests are sent but results are not counted. This lets your server warm up its connection pool, JIT-compile hot paths, and fill any in-memory caches before measurement begins. Cold-start latency is real — a warm-up period removes it from your results.

For time-based tests instead of request-count-based:

h2load -D 30 -c 10 -m 10 https://example.com/

-D 30 runs the test for 30 seconds regardless of how many requests complete. This is better for measuring steady-state throughput because it is not affected by how fast or slow the server responds.


Step 5: Compare HTTP/2 vs HTTP/1.1

h2load can force HTTP/1.1 mode with --h1, which lets you run the same test against the same server on the same connection and compare protocols directly:

# HTTP/2 test
h2load -n 2000 -c 10 -m 20 https://192.168.1.50/api/data

# HTTP/1.1 test (same server, same endpoint)
h2load --h1 -n 2000 -c 10 https://192.168.1.50/api/data

Note that -m is irrelevant in --h1 mode since HTTP/1.1 has no multiplexing. Record the req/s and time for request mean from both runs.

On a properly configured nginx with HTTP/2 enabled (see How to Secure Nginx with Let’s Encrypt SSL Using Certbot for TLS setup), a typical comparison looks like:

Mode Throughput Mean Latency Header Overhead
HTTP/1.1 180 req/s 52ms baseline
HTTP/2 (-m 1) 190 req/s 48ms −62%
HTTP/2 (-m 20) 340 req/s 28ms −62%

The biggest gains appear when -m is greater than 1 and when the test includes many small requests (API endpoints, JSON responses) rather than large file downloads. Large file transfers benefit less from multiplexing because a single stream can saturate the connection anyway.


Step 6: Test a POST Endpoint

h2load supports custom HTTP methods and request bodies via the -d (data file) and -H (custom header) flags:

Create a JSON payload file:

echo '{"username":"loadtest","action":"ping"}' > /tmp/payload.json

Run a POST load test:

h2load \
  -n 2000 \
  -c 10 \
  -m 10 \
  -m 10 \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test' \
  -d /tmp/payload.json \
  https://192.168.1.50/api/events

h2load sends the file contents as the request body for every request. This is useful for benchmarking API ingest endpoints, webhook receivers, or any write-heavy path.

For endpoints that require unique payloads per request (e.g., a user creation endpoint where duplicate usernames would cause 400 errors), h2load is not the right tool — use k6, which lets you generate dynamic payloads in JavaScript per virtual user.


Common Mistakes and Troubleshooting

h2load: protocol error

Your server does not actually support HTTP/2. Many servers advertise TLS support but have not enabled the HTTP/2 protocol. On nginx, verify HTTP/2 is enabled:

grep -r "listen.*http2" /etc/nginx/

You should see something like listen 443 ssl http2; or http2 on; (nginx 1.25+). If not, add it and reload nginx. Confirm with:

curl -I --http2 https://192.168.1.50/

Look for HTTP/2 200 in the response. If you see HTTP/1.1 200, the server fell back.

ERR_SSL_PROTOCOL_ERROR or TLS handshake failure

This usually means the server requires a specific TLS version or cipher that h2load is not offering. HTTP/2 requires TLS 1.2 at minimum, and certain weak cipher suites are forbidden by the HTTP/2 spec. Check your server’s TLS configuration:

openssl s_client -connect 192.168.1.50:443 -alpn h2

Look for ALPN protocol: h2 in the output. If it shows http/1.1 instead, the server is not advertising HTTP/2 via ALPN negotiation.

Results are identical between -m 1 and -m 20

Your server may have http2_max_concurrent_streams set very low, or the connection bandwidth is already saturated. Check nginx’s HTTP/2 stream limit:

grep http2_max_concurrent_streams /etc/nginx/nginx.conf /etc/nginx/conf.d/* 2>/dev/null

The default is 128 streams per connection, which is usually fine. A low number (1 or 2) would explain why increasing -m has no effect.

Extremely high time for connect even with few connections

TLS handshakes are expensive. If time for connect is 100ms+ for local connections, your server may be missing session resumption or OCSP stapling. Check nginx TLS optimization settings — ssl_session_cache, ssl_session_timeout, and ssl_stapling on all reduce per-connection TLS overhead.

All requests fail with errored

The server is refusing connections entirely. Check the server is running, the port is open, and any firewall rules allow the connection. If you set up Fail2ban and the testing machine’s IP got blocked after a previous test run, clear it with:

sudo fail2ban-client set nginx-http-auth unbanip 192.168.1.20

Best Practices

Always specify -m greater than 1 when testing HTTP/2. A test with -c 50 -m 1 does not exercise multiplexing and produces misleading results — you are paying HTTP/2’s setup cost without getting any of its benefits. Start with -c 10 -m 20 as a baseline for API endpoints.

Use -D (duration) for steady-state measurements. Count-based tests (-n) end as soon as the last request completes, which means a slow server produces a shorter test run and might look better per-second than it really is. Time-based tests hold the measurement window constant.

Test on staging, not production. h2load with high -c and -m values can generate thousands of in-flight requests. Even an accidentally short duration at high concurrency can spike CPU and latency for real users. If you want to understand rate limiting behavior under HTTP/2 load, configure a dedicated staging environment.

Pair h2load with server-side monitoring. h2load tells you what the client observed. CPU usage, worker process counts, and database latency on the server tell you why. If you have Prometheus and Grafana running (covered in Set Up Prometheus and Grafana on Ubuntu), run htop and watch the Nginx dashboard while h2load is active.

Record baselines. Run h2load against your current server before making changes, save the output, then re-run after the change. A 20% drop in req/s or a doubling of mean latency after a nginx configuration change is a signal — you would not catch it without a before/after comparison.

Use --warm-up-time on long tests. For any test longer than 30 seconds, add -W 5 to discard the first 5 seconds of results. Server-side JIT, DNS caching, and connection pool initialization all fire in the first few seconds. Steady-state throughput is what matters in production.


Conclusion

You have installed h2load on Ubuntu and used it to run HTTP/2-native load tests against a real server. You understand the two dimensions of HTTP/2 concurrency — connections and streams — and why they are both necessary to exercise multiplexing correctly. You can read the full output block, identify latency outliers, and interpret header compression savings. You have also seen how to test POST endpoints, compare HTTP/2 against HTTP/1.1 on the same server, and debug the most common connection failures.

The most important takeaway: -m 1 load tests of HTTP/2 servers are misleading. The protocol’s value comes from sending many streams per connection, and the only way to measure that value is to use a tool like h2load that models it correctly.

From here, good directions to explore:

  • HTTP/3 / QUIC testing — h2load also supports HTTP/3 (--npn-list h3) on servers that have QUIC enabled. HTTP/3 replaces TCP with QUIC (UDP-based), which eliminates head-of-line blocking entirely. The h2load flags are identical; only the transport changes.
  • Compare h2load with k6 — for scripted, multi-scenario tests with checks and thresholds, k6 is the better tool. Use h2load for quick protocol-level benchmarks and k6 for sustained CI pipeline integration.
  • Profile nginx HTTP/2 push — h2load can receive and measure Server Push frames (PUSH_PROMISE). If your server uses HTTP/2 push to preload critical assets, h2load will show push overhead in the timing breakdown.