HTTP/3 in the Java HTTP Client (JEP 517): Migrating from HttpClient 11
JEP 517 added HTTP/3 to java.net.http in Java 26 as an opt-in. What the opt-in looks like, how the three H3_DISCOVERY modes behave, what fallback costs in seconds, why proxies silently downgrade you, and how HTTP/1.1, HTTP/2 and HTTP/3 compare under simulated packet loss, all measured on a local server with captured transcripts.
Your Java 11 HttpClient code compiles and runs on Java 26 without a single change, and it will keep talking HTTP/2 even to a server that offers HTTP/3. JEP 517 added HTTP/3 to the client in Java 26, and it did so as an opt-in: nothing you already wrote switches protocols behind your back. The interesting questions are what the opt-in looks like, what happens when the server does not cooperate, and whether it is faster on a bad network. This article answers each one with a server on localhost and a transcript you can regenerate.
Two results surprised me while building the demos. The first request of a client that opted in to HTTP/3 on the client did not use HTTP/3 in any of my runs, and one discovery mode can take a full minute to fail against a server that simply does not speak QUIC. Both are explained below with the output that shows them. The packet-loss comparison is included too, with its limits spelled out: it is a loopback test with a Python server, not a verdict on HTTP/3 in production.
Versions. JEP 517 (HTTP/3 for the HTTP Client API) is delivered in Java 26 (GA 2026-03-17, per openjdk.org/projects/jdk/26). Everything here ran on Temurin 26.0.2.1+1, with Temurin 25.0.4.1+1 for the “before” runs. The test server is Hypercorn (HTTP/1.1 and HTTP/2 on TCP) plus a small aioquic server (HTTP/3 on UDP), both Python, on a 2-vCPU virtual machine. Code and transcripts are in the http3 module of the javademos repository.
Java 11 HttpClient code already runs on 26, and it still chooses HTTP/2
A quick reminder of the starting point. java.net.http.HttpClient arrived in Java 11 (JEP 321) and speaks HTTP/1.1 and HTTP/2. Unless you say otherwise it prefers HTTP/2 and quietly drops to HTTP/1.1 when the server cannot do it. You never write the protocol anywhere; you can only read it afterwards from HttpResponse.version().
var client = Tls.builder().build(); // the only non-11 line is the self-signed trust
var request = HttpRequest.newBuilder(URI.create(args[0])).GET().build();
for (int i = 1; i <= 3; i++) {
HttpResponse<String> r = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("request " + i + ": " + r.version() + " " + r.statusCode() + " alt-svc=" + r.headers().firstValue("alt-svc").orElse("-"));
}
This is ordinary Java 11 code (the one extra line in the repository file builds a client that trusts the test server’s self-signed certificate). I ran it, unchanged, on Java 25 and Java 26 against a server that speaks HTTP/1.1, HTTP/2 and HTTP/3. Every response came back as HTTP/2 on both JDKs, even though the server’s alt-svc header was telling the client that HTTP/3 was available on UDP port 4433 (output: 01-java11-code-unchanged.txt):
The diagram is the reason HTTP/3 support is an opt-in with a discovery problem rather than a flag. HTTP/1.1 and HTTP/2 run over TCP; HTTP/3 runs over QUIC, which runs over UDP. A client that is already talking TCP to a server cannot upgrade that connection. It has to open a second, different kind of connection and hope a server is listening for QUIC at the other end. JEP 517 states the consequence plainly: it is impossible to determine in advance whether a target server supports HTTP/3.
Trap: response.version() is the only honest witness. Nothing in your code changes when the client falls back, and nothing is logged. If you care which protocol carried a request, read HttpResponse.version(). The benchmark later in this article asserts it on every single response for exactly that reason.
Opting in is one line, and the first request may still use HTTP/2
You opt in by naming HTTP_3 as the preferred version, either on the client, which covers every request it sends, or on a single request:
var builder = Tls.builder();
if (where.equals("client")) builder.version(HttpClient.Version.HTTP_3);
var client = builder.build();
var req = HttpRequest.newBuilder(uri);
if (where.equals("request")) req.version(HttpClient.Version.HTTP_3);
The repository’s OptIn program does both, sends four requests and prints the version each response used (output: 02-opt-in.txt). On Java 26 against the HTTP/3-capable server:
The two variants are not equivalent. With the preference on the client, request 1 came back as HTTP/2 and requests 2 to 4 as HTTP/3. With the preference on the request, every response was HTTP/3. I repeated the client-level run in eight fresh JVMs and request 1 was HTTP/2 every time (same file, last block). This is the documented behaviour, not a bug: the JEP says that when only the client prefers HTTP/3 the first request is attempted over HTTP/3 and over TCP in parallel, and whichever connects first wins. On this machine the TCP and TLS handshake won the race against the Python QUIC server, and the later requests used HTTP/3, presumably because of the Alt-Svc header the server sent (I did not isolate that). A faster server may win the race the other way; I did not test that.
On Java 25 this does not compile. The same file on JDK 25 fails with cannot find symbol ... symbol: variable HTTP_3 ... location: class Version (first lines of the third block in 02-opt-in.txt). Code that mentions HTTP_3 needs Java 26 to build. That matters for libraries: they cannot reference the constant if they still compile for Java 17 or 21.
Going deeper
Client-level vs request-level preference, from JEP 517
Three discovery modes decide how the client finds an HTTP/3 server
Because a client cannot ask a server “do you speak HTTP/3?” over TCP, Java 26 adds one request option, HttpOption.H3_DISCOVERY, with three values. You set it with the new setOption method on HttpRequest.Builder. The option only matters if HTTP/3 is preferred somewhere; otherwise the client ignores it.
for (Http3DiscoveryMode mode : Http3DiscoveryMode.values()) {
var client = Tls.builder().version(HttpClient.Version.HTTP_3).build();
var req = HttpRequest.newBuilder(uri).setOption(HttpOption.H3_DISCOVERY, mode).build();
Mode
What the client does
Per the Javadoc
ANY (the default)
Tries HTTP/3 and TCP, uses whichever succeeds first; may use a remembered Alt-Svc record, else tries UDP at the same host and port
implementation-specific algorithm
ALT_SVC
Sends to HTTP/1.1 or HTTP/2 until the server advertises an h3 alternative service, then switches
only Alternative Services are used
HTTP_3_URI_ONLY
Attempts only HTTP/3 at exactly the URI’s host and port, never falls back
Alt-Svc is not used
Run against the server that speaks all three protocols (output: 03-discovery-modes.txt), each mode made four requests from a fresh client:
The picture is the output reduced to colours. ALT_SVC behaves exactly as its Javadoc promises: it cannot use HTTP/3 until a first answer has advertised it, so request 1 is HTTP/2. ANY produced the same sequence here, for the race reason above. HTTP_3_URI_ONLY is the only one that committed to QUIC from the first request, and the next section shows what that commitment costs when the server is not there.
Going deeper
The promise of the opt-in is that a server without HTTP/3 still works: the client downgrades to HTTP/2 or HTTP/1.1. That held in every test, and I could not make a fallback fail. What the Javadoc does not quantify is how long the downgrade takes, so I measured it with three servers that cannot do HTTP/3: an HTTP/2 server with nothing listening on UDP, an HTTP/1.1-only server, and an HTTP/3-capable server behind a firewall rule that drops all UDP on the port (an iptables rule, to imitate an office network that blocks QUIC).
var cb = Tls.builder();
if (mode.startsWith("client")) cb.version(HttpClient.Version.HTTP_3);
var rb = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(limitSeconds));
if (!mode.startsWith("client")) rb.version(HttpClient.Version.HTTP_3);
if (mode.endsWith("URI_ONLY")) rb.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY);
$ java src/Fallback.java https://localhost:4433/hello 120 (server: h2 + h1.1 on TCP only; nothing listens on UDP 4433)
request HTTP_3 (ANY) -> HTTP_2 after 3340 ms
client HTTP_3 (ANY) -> HTTP_2 after 31 ms
request HTTP_3_URI_ONLY -> java.net.ConnectException: No response from peer for 30 seconds after 60016 ms
Three facts fall out of that transcript and the UDP-blocked one (05-udp-blocked.txt). Preferring HTTP/3 on the client answered over HTTP/2 after 31 ms (firewalled: 46 ms), because it races TCP against QUIC. Preferring it on the request only answered after 3340 ms (firewalled: 3397 ms), because it waits for QUIC to fail before trying TCP: the “first approach” in the JEP. And HTTP_3_URI_ONLY did not answer at all: it threw ConnectException: No response from peer for 30 seconds, after about 60 seconds by my stopwatch, well inside the 120-second request timeout I had set.
The chart is the practical advice in one picture: if some of your targets may not speak HTTP/3, putting the preference on the client is the cheap way to be safe, and putting it on individual requests is the way to pay a multi-second tax on each new connection to a non-HTTP/3 host. HTTP_3_URI_ONLY is for services you control and have verified.
The fingerprint of HTTP_3_URI_ONLY against a server that cannot do QUIC. A java.net.ConnectException whose message is “No response from peer for 30 seconds” and that arrives after a minute, not after 30 seconds. The same message appeared four times in a row in the HTTP/1.1-only run of Discovery (last block of 04-fallback.txt). I did not investigate why it takes two timeouts to give up.
The JDK lets you shorten the waits with system properties that I found by searching the java.net.http module’s class files for strings. They are not in the Javadoc, so treat them as implementation details that may change between releases. With -Djdk.httpclient.http3.maxDirectConnectionTimeout=1 the request-level fallback took 501 ms instead of 3340 ms, and with -Djdk.httpclient.quic.maxInitialTimeout=3 the HTTP_3_URI_ONLY failure arrived after 6011 ms with “No response from peer for 3 seconds”.
Proxies downgrade HTTP/3 silently, unless you forbade it
The Javadoc for H3_DISCOVERY contains a sentence worth reading before you deploy behind a corporate proxy: in this implementation, HTTP/3 through proxies is not supported. Unless HTTP_3_URI_ONLY is specified, a request that selects a proxy is downgraded to HTTP/2 or HTTP/1.1 and the option is ignored; with HTTP_3_URI_ONLY the request fails. I checked both with a small CONNECT proxy (connect-proxy.py):
var client = Tls.builder().version(HttpClient.Version.HTTP_3)
.proxy(ProxySelector.of(new InetSocketAddress("127.0.0.1", proxyPort))).build();
var rb = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(10));
if (uriOnly) rb.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY);
$ java src/ViaProxy.java https://localhost:4433/hello 4480 (client.proxy(...) points at a CONNECT proxy on 4480)
ANY via proxy -> HTTP_2
HTTP_3_URI_ONLY via proxy -> java.net.http.UnsupportedProtocolVersionException: can't use HTTP/3 with proxied or unsecured connection
$ proxy log
proxy saw: CONNECT localhost:4433
With the default mode the response arrived as HTTP/2 and the proxy log shows a CONNECT for localhost:4433: the traffic went through the proxy over TCP and HTTP/3 was never attempted. With HTTP_3_URI_ONLY the client threw UnsupportedProtocolVersionException: can't use HTTP/3 with proxied or unsecured connection. The second half of that message is also worth remembering: HTTP/3 requires https, so an http:// URI cannot use it (I did not run that case).
Trap for container and CI environments. Many JVM setups set -Dhttps.proxyHost through JAVA_TOOL_OPTIONS or a ProxySelector. A client that “opted in” to HTTP/3 in such an environment will look fine, return 200, and be running HTTP/2 through the proxy the whole time. Log response.version() once at startup if you need to know.
Under packet loss, HTTP/3 was steadier, not faster, in my test
The promise in the JEP is “more reliable transport, especially in environments with high rates of packet loss”. I tried to measure that, and I want to be upfront about how limited the setup is. The Linux kernel in my sandbox has no tc netem (tc qdisc add ... netem answered Specified qdisc kind is unknown), so I could not add realistic delay or use a network emulator. What I could do, as root, was use iptables -m statistic --mode random on the loopback interface to drop a fixed percentage of packets in each direction, for TCP and UDP port 4433 alike (loss.sh). That gives loss and no added latency: the round trip stays a few microseconds, so anything HTTP/3 saves by needing fewer round trips is invisible here. I also lowered the loopback MTU from 65536 to 1500 while loss was on, because otherwise TCP would send 64 KiB segments while QUIC sends roughly 1.2 KB datagrams and the same per-packet drop rate would be wildly unfair.
Each trial creates a fresh client and downloads from the same server process over HTTP/1.1, HTTP/2 or HTTP/3. The one workload is a single 1 MiB download. The many workload is ten parallel 100 KiB downloads. HTTP/3 runs use HTTP_3_URI_ONLY, so a silent fallback cannot flatter the result, and every response is checked for the requested version and exact body length (Bench.java):
for (int t = -1; t < trials; t++) { // trial -1 is a warm-up and is discarded
long t0 = System.nanoTime();
try (var client = Tls.builder().version(version).build()) {
var rb = HttpRequest.newBuilder(URI.create("https://localhost:4433/bytes/" + size)).timeout(Duration.ofSeconds(15));
if (version == HttpClient.Version.HTTP_3) rb.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY);
var req = rb.build();
List<CompletableFuture<HttpResponse<byte[]>>> fs = new ArrayList<>();
for (int i = 0; i < n; i++) fs.add(client.sendAsync(req, HttpResponse.BodyHandlers.ofByteArray()));
for (var f : fs) {
var r = f.get();
if (r.version() != version || r.body().length != size) throw new IllegalStateException(r.version() + " " + r.body().length);
}
if (t >= 0) times.add((System.nanoTime() - t0) / 1_000_000);
Twenty trials per row after one discarded warm-up, in milliseconds (complete transcript: 07-packet-loss.txt). The ten-parallel-downloads rows at 0%, 2%, 5% and 10% loss:
Ten streams at once is where HTTP/3 pulled ahead. At 0% loss it was the slowest of the three (median 135 ms against 97 for HTTP/1.1 and 118 for HTTP/2). At 2% the three were within noise of each other on the median, but the tail already differed: the 90th percentile was 296 ms for HTTP/3 against 1058 and 1056. At 5% loss the medians were 322 ms (HTTP/3), 1055 (HTTP/1.1) and 1047 (HTTP/2); at 10% they were 885, 1509 and 1478. The HTTP/1.1 and HTTP/2 numbers cluster around 1,000 ms at modest loss, which is consistent with TCP waiting out a one-second retransmission timeout, a hypothesis I did not verify here. HTTP/3 has no such cliff in these runs.
One big download told a less flattering story. For a single 1 MiB stream HTTP/3 had the highest median at every loss level (for example 968 ms at 10% loss against 322 for HTTP/1.1 and 678 for HTTP/2), though at 5% and 10% loss its 90th percentile was the lowest of the three (at 10% loss 1169 ms against 1578 and 2177). At 0% loss it was also about twice as slow to complete (129 ms against 59 and 61). I cannot say from this test whether that is the JDK’s QUIC implementation, my Python aioquic server, or per-datagram cost on a loopback with no latency to hide it. Do not read either as a statement about HTTP/3 in general.
What this test does and does not show. It shows that, with the same random loss on both protocols, JDK 26’s HTTP/3 client completes parallel downloads with a much tighter tail than its HTTP/1.1 and HTTP/2 clients, and that none of the 600 measured trials failed (see the failed= column). It does not show real-network behaviour: there is no latency, no bandwidth limit, no bursty loss, and only one server implementation. Loss was random, so another run gives different numbers; treat these as a shape. The server matters: my first attempt served HTTP/3 through Hypercorn’s own QUIC listener, which silently stopped answering after an internal KeyError under loss and stalled the client. I replaced it with a small aioquic server and all rows here come from that (h3server.py; no server exceptions were logged during the run, see 08-server-errors.txt).
Reference: the complete loss table (median / p90 / max, ms)
Every row of 07-packet-loss.txt in one table. one = one 1 MiB download, many = ten parallel 100 KiB downloads.
Migrating from HttpClient 11: what to change, and whether to bother
The migration is small, and most of it is deciding, not coding. A checklist from what the demos showed:
Do nothing if you are happy with HTTP/2: Java 11 code runs unchanged on 26 and stays on HTTP/2 (first section).
Build on Java 26, since HttpClient.Version.HTTP_3, HttpOption and setOption do not exist earlier (second section).
Prefer HTTP/3 on the client if your targets are mixed: it races TCP against QUIC and falls back quickly. Prefer it per request only for hosts you know support it, or every first request to another host waits for a timeout (fallback section).
Reserve HTTP_3_URI_ONLY for endpoints you own. Against a server without QUIC it fails after about a minute with ConnectException (fallback section).
Check response.version() in a test or a startup log line; proxies and blocked UDP downgrade you without any error (fallback and proxy sections).
Use https and the default TLS provider. JEP 517 says third-party security providers are not supported in this first implementation.
Should you switch? My opinion, from these results: turn it on where you talk to servers known to offer HTTP/3 (large CDNs and public APIs) and your clients run on networks where packet loss is realistic, such as mobile or congested links, and keep HTTP/2 where you call internal services over a clean data-centre network. On my loopback test HTTP/3 cost more time on a clean path and a single large transfer, and only paid off for many parallel streams under loss. That is one environment and one server; measure your own traffic before you change a default. JEP 517 itself says it is not proposing to make HTTP/3 the default.
No Comments yet!