Your service calls an API that has advertised h3 for two years. The JDK client kept using HTTP/2, so somebody suggested pulling in Netty or a native QUIC binding just to get the newer protocol.

Java 26 removes that reason. The same java.net.http.HttpClient you have used since Java 11 can now carry a request over HTTP/3 — no new client, no new dependency, no rewritten call sites.

What it is not is automatic. HTTP/3 is opt-in; the default preferred version is still HTTP/2.

var client = HttpClient.newBuilder()
        .version(HttpClient.Version.HTTP_3)
        .build();

That one builder line is the whole API change for most code. This post covers what it actually does, how the client decides whether HTTP/3 is even possible, and the three cases where it quietly gives you HTTP/2 instead.

When it shipped

ReleaseStatusSpec
Java 11HttpClient standard (HTTP/1.1 + HTTP/2)JEP 321
Java 26HTTP/3 support, finalJEP 517

There is no preview flag and no incubator module. HttpClient.Version.HTTP_3 and the new java.net.http.HttpOption types are ordinary API in java.net.http on Java 26.

Note: This is client-side only. JEP 517 explicitly does not ship a server-side HTTP/3 implementation, and it does not expose a public QUIC API.

QUIC in one section

HTTP/1.1 and HTTP/2 ride on a TCP stream. HTTP/3 rides on QUIC, which is built on UDP datagrams and is always secured with TLS 1.3.

Two consequences matter for the API, and you can skip the rest of the RFCs:

  • You cannot upgrade into it. An open HTTP/1.1 or HTTP/2 connection can never become HTTP/3, because it is the wrong transport underneath. The client has to make a separate connection attempt.
  • You cannot ask a URL in advance. https://example.com/ looks identical whether or not the origin listens for QUIC. Something has to probe, race, or be told.

That is why HTTP/3 needs a discovery story where HTTP/2 needed only ALPN on an existing TCP handshake. Everything awkward about the next few sections comes from those two facts.

QUIC’s payoff is the usual list: faster handshakes, no TCP head-of-line blocking when one packet is lost, and better behaviour on lossy networks. You get that by asking for the version — you do not tune QUIC yourself.

Mental model: prefer, then fall back

Setting HTTP_3 sets a preference, not a requirement. By default the client is allowed to give you something older.

You setClient attemptsIf HTTP/3 is unavailable
NothingHTTP/2Downgrades to HTTP/1.1
HTTP_3HTTP/3Transparently falls back to HTTP/2 or HTTP/1.1
HTTP_3 + HTTP_3_URI_ONLYHTTP/3 onlyRequest fails

So the honest description of version(HTTP_3) is “try the good one first, keep working if it is not there.” Your send call, body handlers, headers, and exception handling are unchanged.

Because the version you asked for is not necessarily the version you got, read it off the response:

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.version()); // HTTP_3, HTTP_2, or HTTP_1_1

HttpResponse.version() reports what the exchange actually used. Log it once during rollout; it is the only reliable way to know whether HTTP/3 is really happening in production.

Turn it on: client or request

You can opt in at two levels, and the choice changes the connection strategy rather than just the scope.

Set it on the client when you want every request from that client to prefer HTTP/3:

var client = HttpClient.newBuilder()
        .version(HttpClient.Version.HTTP_3)
        .connectTimeout(Duration.ofSeconds(5))
        .build();

Set it on a single request when only one endpoint is known to speak HTTP/3:

var request = HttpRequest.newBuilder(URI.create("https://example.com/api/orders"))
        .version(HttpClient.Version.HTTP_3)
        .GET()
        .build();

The difference is real. With a request preference of HTTP_3, the client tries HTTP/3 first and falls back only after the attempt fails to complete in reasonable time. With a client preference of HTTP_3 and no version on the request, the client may race an HTTP/3 connection against a TLS/TCP connection and use whichever completes first.

Racing costs a wasted connection; probing costs a timeout. Neither is wrong — pick based on whether you would rather burn a socket or a few hundred milliseconds.

How the client finds an HTTP/3 endpoint

Discovery is controlled by one per-request option, HttpOption.H3_DISCOVERY, whose value is an HttpOption.Http3DiscoveryMode.

ModeBehaviourUse when
ANYImplementation’s own strategy: may attempt QUIC and TLS/TCP and take the winner. Uses cached Alt-Svc info if it has any. Default.You do not know, and you want it to work either way
ALT_SVCOnly use HTTP Alternative Services. Requests go over HTTP/1.1 or HTTP/2 until the origin advertises an h3 endpointYou want zero speculative QUIC attempts
HTTP_3_URI_ONLYOnly attempt HTTP/3 at the authority in the URI. No Alt-Svc, no fallbackYou already know the origin serves HTTP/3

If you never call setOption, and HTTP/3 is preferred on the client or the request, the JDK implementation behaves as ANY. Most code should leave it alone.

The option lives on the request builder, not the client builder:

var request = HttpRequest.newBuilder(URI.create("https://example.com/api/orders"))
        .version(HttpClient.Version.HTTP_3)
        .setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.ALT_SVC)
        .GET()
        .build();

Note: H3_DISCOVERY does nothing on its own. If neither the request nor the client prefers HTTP_3, the option is ignored entirely. It is a hint about how to reach HTTP/3, not a way to ask for it.

Alt-Svc, briefly

ALT_SVC leans on RFC 7838. A server answering over HTTP/2 can include an Alt-Svc header advertising an h3 endpoint; the client remembers it and uses HTTP/3 for later requests to that origin.

alt-svc: h3=":443"; ma=86400

The trade is obvious in the mode table: the first request is never HTTP/3, and it only ever becomes HTTP/3 if the server volunteers. That is exactly what browsers do, and it is the gentlest mode for a service that talks to many origins you do not control.

Lab: prefer HTTP/3 and print what you got

This is the smallest program that proves whether HTTP/3 is reaching a given host. Save it as Http3Probe.java — a single-file source program, so it needs no build tool.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

void main() throws Exception {
    // The origin must actually serve HTTP/3; swap in a host you control.
    var uri = URI.create("https://cloudflare-quic.com/");

    var client = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_3)
            .connectTimeout(Duration.ofSeconds(5))
            .build();

    var request = HttpRequest.newBuilder(uri)
            .version(HttpClient.Version.HTTP_3)
            .timeout(Duration.ofSeconds(10))
            .GET()
            .build();

    HttpResponse<String> response =
            client.send(request, HttpResponse.BodyHandlers.ofString());

    System.out.println("status:    " + response.statusCode());
    System.out.println("negotiated " + response.version());
    System.out.println("bytes:     " + response.body().length());
}

Run it directly on a Java 26 JDK:

java --version
java Http3Probe.java

When QUIC reaches the origin:

status:    200
negotiated HTTP_3
bytes:     5482

When it does not — UDP/443 blocked by a corporate firewall, a proxy in the path, or a server that never enabled h3 — you get the same 200 over an older protocol:

status:    200
negotiated HTTP_2
bytes:     5482

Both runs succeed. That is fallback working as designed, and it is why you must print response.version() rather than assume.

Confirm the server side independently

Before blaming the JDK, check that the origin really advertises HTTP/3. A recent curl built with HTTP/3 support answers in one command:

curl -sI --http3 https://cloudflare-quic.com/ | head -n 1
curl -sI https://cloudflare-quic.com/ | grep -i alt-svc

If curl --http3 fails too, the problem is the network or the server, not your client configuration.

See what the client decided

The long-standing HttpClient logging property still works and is the fastest way to watch a fallback happen:

java -Djdk.httpclient.HttpClient.log=requests,headers,errors Http3Probe.java

Refuse to fall back

Fallback is a good default and a bad test. If you are verifying that an internal service actually serves HTTP/3, silent downgrade turns a red build green.

HTTP_3_URI_ONLY removes the safety net: the client only attempts HTTP/3 at the exact host and port in the URI, ignores Alternative Services, and fails if that connection cannot be established.

var strict = HttpRequest.newBuilder(URI.create("https://internal.example.com/health"))
        .version(HttpClient.Version.HTTP_3)
        .setOption(HttpOption.H3_DISCOVERY,
                   HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY)
        .GET()
        .build();

Use it in integration tests and conformance checks. Do not ship it on a user-facing path unless you own both ends and accept that a blocked UDP port is now an outage.

One useful detail for redirect-heavy APIs: the H3_DISCOVERY option is always transferred to the new request when the client follows a redirect, so a strict check stays strict across hops.

Gotchas

HTTPS only

QUIC is secured with TLS 1.3, so HTTP/3 exists only over https://. A plain http:// URI will never negotiate HTTP/3 no matter what you set on the builder — it will use HTTP/1.1 as before.

Proxies are not supported

The JDK implementation does not do HTTP/3 through a proxy. If a proxy is selected for the request URI, the version is downgraded to HTTP/2 or HTTP/1.1 and H3_DISCOVERY is ignored.

The exception is the strict mode, and it is a sharp one: with HTTP_3_URI_ONLY and a proxy in the path, the request fails instead of downgrading. If your ProxySelector covers corporate hosts, expect that combination to break there and work on your laptop.

Only the default TLS provider

The first implementation supports the built-in SunJSSE provider only. Third-party secure-socket providers are a stated non-goal of JEP 517, so a custom JSSE provider in your stack means no HTTP/3 yet.

No QUIC API, and java.net.URL is untouched

There is no public API for QUIC itself — you get HTTP/3 or nothing. The legacy java.net.URL protocol handler is also unchanged and still speaks HTTP/1.1 only, so old URL.openConnection() code gains nothing here.

Fallback hides misconfiguration

The recurring theme: an HTTP/3 request that silently becomes HTTP/2 looks exactly like success. Assert on response.version() in tests, or use HTTP_3_URI_ONLY there, and log the negotiated version during rollout.

Cheat sheet

Java 26 / JEP 517: final, no preview flag, same java.net.http.HttpClient

Opt in on the client:   HttpClient.newBuilder().version(HttpClient.Version.HTTP_3)
Opt in on the request:  HttpRequest.newBuilder(uri).version(HttpClient.Version.HTTP_3)
Read what you got:      response.version()  -> HTTP_3 | HTTP_2 | HTTP_1_1

Discovery (request option only, needs HTTP_3 preferred to have any effect):
  request.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.X)
    ANY              default; may race QUIC vs TLS/TCP, may use cached Alt-Svc
    ALT_SVC          HTTP/1.1 or HTTP/2 until the origin advertises h3
    HTTP_3_URI_ONLY  HTTP/3 at the URI authority only; no Alt-Svc, no fallback

Default preferred version is still HTTP/2 — HTTP/3 is never automatic
Client pref only     -> may race HTTP/3 against TLS/TCP, first wins
Request pref         -> tries HTTP/3 first, falls back after a timeout
HTTPS only; no HTTP/3 through a proxy; SunJSSE provider only
HTTP_3_URI_ONLY + proxy = request fails, not downgrades
H3_DISCOVERY carries across redirects
Client-side only: no server implementation, no public QUIC API

Do:

  • Opt in on the client for services that talk to one HTTP/3-capable origin.
  • Print or assert response.version() instead of trusting the preference.
  • Use HTTP_3_URI_ONLY in tests that must prove HTTP/3 is really in use.
  • Reach for ALT_SVC when you call many origins and want browser-like behaviour.
  • Verify the server with curl --http3 before debugging your Java code.

Don’t:

  • Claim HTTP/3 is on because you set version(HTTP_3) — fallback is silent.
  • Set H3_DISCOVERY without also preferring HTTP_3; it is ignored.
  • Expect HTTP/3 on http:// URIs, through a proxy, or with a third-party TLS provider.
  • Ship HTTP_3_URI_ONLY on user-facing calls you cannot guarantee end to end.
  • Go looking for a QUIC API or an HTTP/3 server in the JDK — neither exists.

Wrap-up

JEP 517 is a small API change hiding a large implementation. The client is the same one from Java 11; you add version(HttpClient.Version.HTTP_3) and everything else — requests, body handlers, timeouts, redirects — keeps working.

The part worth internalizing is the negotiation. HTTP/3 cannot be upgraded into and cannot be detected in advance, so the client either races, waits out a timeout, or waits for an Alt-Svc advertisement. That is also why the default preferred version stays HTTP/2 for now.

Treat the version as an outcome rather than a setting: prefer HTTP/3 broadly, let fallback protect you in production, and use HTTP_3_URI_ONLY in the one place where a downgrade should be a failure.