HTTP 2 and HTTP 3 in Go and What Works Out of the Box
See what Go gives you for HTTP/2 and HTTP/3 with no extra code, count connections to prove multiplexing, and use cleartext h2c with http.Protocols.

Most Go services speak HTTP/2 and nobody on the team wrote a line to make that happen. Others never do, because one innocent customisation of the transport quietly turned it off. The only way to know which camp you are in is to look at res.Proto and count connections, and the experiments are short enough to run before lunch.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article covers what net/http does by default for HTTP/2, how to prove it, what changed in Go 1.24 for cleartext HTTP/2, and what the situation is for HTTP/3. It is part of the web cluster that ends in a production HTTP server, and it matters most for the client settings in the transport article and connection reuse. Everything ran on Go 1.25.6.
What you get without writing anything
On the server, http.ListenAndServeTLS and Server.ServeTLS enable HTTP/2 automatically. During the TLS handshake the client and server agree on a protocol through ALPN, and h2 wins if both offer it. A server that is not using TLS, like ListenAndServe, speaks HTTP/1.1 only.
On the client, a plain http.Client{} or http.DefaultClient uses http.DefaultTransport, which tries HTTP/2 for https URLs and falls back to HTTP/1.1 if the server does not offer it. Your handler code does not change: r.Proto says HTTP/2.0, headers are the same, and Flusher still works. What changes is everything beneath. Requests share one connection, headers are compressed, and a slow request no longer blocks the ones behind it on the same socket.
The one trap is on the client. HTTP/2 is only enabled automatically for the default transport settings. If you build a Transport and set TLSClientConfig, DialContext, DialTLSContext or similar, Go assumes you want to control the connection and stays on HTTP/1.1 unless you set ForceAttemptHTTP2: true. Plenty of "tuned" clients in production have been HTTP/1.1 for years because of that one line.
Proving it with a connection count
The test sends one request to warm the client up, then ten concurrent requests to a handler that waits 50 ms, once against a TLS server with HTTP/2 off and once with it on, and counts how many TCP connections the server accepted.
package main
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond) // long enough for the requests to overlap
fmt.Fprint(w, r.Proto)
}
// burst sends ten requests at once and returns the protocol reported and the connections the server saw.
func burst(srv *httptest.Server, conns *atomic.Int64) (string, int64) {
client := srv.Client()
var wg sync.WaitGroup
proto := ""
var mu sync.Mutex
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
res, err := client.Get(srv.URL)
if err != nil {
panic(err)
}
res.Body.Close()
mu.Lock()
proto = res.Proto
mu.Unlock()
}()
}
wg.Wait()
return proto, conns.Load()
}
func newServer(h2 bool) (*httptest.Server, *atomic.Int64) {
var conns atomic.Int64
srv := httptest.NewUnstartedServer(http.HandlerFunc(handler))
srv.EnableHTTP2 = h2
srv.Config.ConnState = func(_ net.Conn, s http.ConnState) {
if s == http.StateNew {
conns.Add(1)
}
}
srv.StartTLS()
return srv, &conns
}
func main() {
for _, h2 := range []bool{false, true} {
srv, conns := newServer(h2)
srv.Client().Get(srv.URL) // warm up: one request first, so the connection exists before the burst
proto, n := burst(srv, conns)
fmt.Printf("server h2=%-5v client saw %-8s server accepted %d connections\n", h2, proto, n)
srv.Close()
}
}server h2=false client saw HTTP/1.1 server accepted 11 connections
server h2=true client saw HTTP/2.0 server accepted 1 connectionsI left the warm-up out of my first version, and HTTP/2 accepted ten connections as well. That is real behavior, not a bug in the test: ten requests that start together all find no connection yet, so each dials its own, and only later requests share one. A service that fans out at start-up pays that once. With the warm-up, the HTTP/1.1 run ended with eleven sockets: the warm-up's, plus ten for the burst, since an HTTP/1.1 connection carries one request at a time and the transport opens more to run them in parallel. On HTTP/2 the same ten requests were ten streams on the one socket. At ten requests that is a curiosity. At a thousand requests per second to one upstream it is the difference between holding a thousand sockets and holding a handful, and between paying TLS handshakes constantly and paying one.
The flip side is head-of-line blocking at the TCP layer. All those streams share one TCP connection, so a single lost packet stalls all of them until it is retransmitted. On clean data-centre networks you will not notice it. On lossy mobile links you can, and that is the problem HTTP/3 was designed to fix.
Cleartext HTTP/2 and http.Protocols
Browsers only speak HTTP/2 over TLS, but servers talking to each other inside a cluster often do not want TLS on every hop, and gRPC is the best-known case. That mode is called h2c. Before Go 1.24 it needed the golang.org/x/net/http2/h2c package. Since 1.24 it is part of net/http, selected by the new http.Protocols type.
package main
import (
"fmt"
"io"
"net"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, r.Proto)
})
// The server accepts HTTP/1 and cleartext HTTP/2 on the same port.
var sp http.Protocols
sp.SetHTTP1(true)
sp.SetUnencryptedHTTP2(true)
srv := &http.Server{Handler: mux, Protocols: &sp}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
go srv.Serve(ln)
defer srv.Close()
url := "http://" + ln.Addr().String()
get := func(label string, p *http.Protocols) {
client := &http.Client{Transport: &http.Transport{Protocols: p}}
res, err := client.Get(url)
if err != nil {
fmt.Printf("%-24s error: %v\n", label, err)
return
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Printf("%-24s proto reported: %s\n", label, body)
}
var h1 http.Protocols
h1.SetHTTP1(true)
get("client HTTP/1 only", &h1)
var h2c http.Protocols
h2c.SetUnencryptedHTTP2(true)
get("client h2c only", &h2c)
var h2only http.Protocols
h2only.SetHTTP2(true) // the TLS flavour, pointless against a cleartext URL
get("client TLS h2 only", &h2only)
}client HTTP/1 only proto reported: HTTP/1.1
client h2c only proto reported: HTTP/2.0
client TLS h2 only proto reported: HTTP/1.1The server decides per connection: if the first bytes are the HTTP/2 connection preface, it switches to h2c, otherwise it speaks HTTP/1.1. A client with only SetUnencryptedHTTP2(true) uses what is called prior knowledge, sending the preface straight away without an upgrade dance. The third case surprised me. A transport that allows only the TLS flavour of HTTP/2, pointed at an http:// URL, did not fail: it spoke HTTP/1.1 and the server answered it. Treat Protocols as a statement of what is permitted, not a guarantee of what you get, and read res.Proto when it matters.
Two warnings about h2c. It exposes the same handler to a wider set of clients, so any middleware that assumes HTTP/1.1 behavior (such as hijacking the connection for a WebSocket) should be tested under h2c. And it is not encryption; keep it inside networks you trust, or put mutual TLS in the service mesh. For http.Protocols itself, the package documentation lists the three methods and their defaults.
HTTP/3 is another story
HTTP/3 runs over QUIC, which runs over UDP, with TLS 1.3 built into the transport. The Go standard library does not implement QUIC and net/http has no HTTP/3 server or client. The mature option is the third-party package github.com/quic-go/quic-go and its http3 subpackage, which provides a server that can sit beside your normal one and advertises itself with an Alt-Svc header so that browsers upgrade on the next request. I have not run it for this article, and I will not describe its API from memory, since it has changed between releases. Its documentation is the source to follow.
My advice is practical rather than theoretical. Whether you need HTTP/3 in the Go process at all is a question about where TLS ends. If a CDN or a reverse proxy such as Caddy, nginx or an ingress controller terminates connections from browsers, then HTTP/3 lives there, and it talks HTTP/1.1 or HTTP/2 to your Go service over a fast, reliable internal network where HTTP/3's advantages barely apply. Running HTTP/3 yourself makes sense when Go terminates the public connection directly, or when you are writing a client or tool that needs it.
You can also check what a deployed site negotiates with curl --http3 -I https://example.com if your curl was built with support, and Chrome's network panel shows a Protocol column with h3.
Checklist for an existing service
- Look at
r.Protoin a log line or a test. If production traffic behind a proxy isHTTP/1.1, check where the proxy terminates; it may be talking HTTP/1.1 to you on purpose. - Audit every custom
http.Transport. If it setsTLSClientConfigor a dialer, decide whether you want HTTP/2 and setForceAttemptHTTP2accordingly. - Set
http.Server.Protocolsexplicitly only when you need h2c or want to switch HTTP/2 off, for example to compare behavior. The default is right otherwise. - Remember that HTTP/2 limits concurrent streams per connection (100 by default on Go servers, tunable through
http2.Serveroptions inx/net), so one connection does not mean unlimited parallelism.


