Skip to content
gohacks.dev
Web

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.

8 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

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.

main.goGo
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()
	}
}
Text
server h2=false  client saw HTTP/1.1  server accepted 11 connections
server h2=true   client saw HTTP/2.0  server accepted 1 connections

I 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.

main.goGo
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)
}
Text
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.1

The 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

  1. Look at r.Proto in a log line or a test. If production traffic behind a proxy is HTTP/1.1, check where the proxy terminates; it may be talking HTTP/1.1 to you on purpose.
  2. Audit every custom http.Transport. If it sets TLSClientConfig or a dialer, decide whether you want HTTP/2 and set ForceAttemptHTTP2 accordingly.
  3. Set http.Server.Protocols explicitly only when you need h2c or want to switch HTTP/2 off, for example to compare behavior. The default is right otherwise.
  4. Remember that HTTP/2 limits concurrent streams per connection (100 by default on Go servers, tunable through http2.Server options in x/net), so one connection does not mean unlimited parallelism.
Also inNetworking
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.