HTTP/2 and HTTP/3 Support in Go

Web
Go enables HTTP/2 by default and supports HTTP/3 via the x/net/http3 package, with GODEBUG settings available to disable HTTP/2.

The protocol negotiation trap

You deploy a Go API. It works perfectly in your tests. You ship it to production. Suddenly, a monitoring alert fires: latency spikes for mobile users. Or worse, an internal service written in an older language starts returning 502 errors. The root cause isn't your business logic. It's the HTTP version.

Go's net/http package negotiates HTTP/2 automatically when you use TLS. This is usually the right behavior. HTTP/2 multiplexes requests over a single connection, compresses headers, and reduces latency. But automatic negotiation hides complexity. When a client doesn't support HTTP/2, or a proxy mangles frames, the fallback behavior can be confusing. HTTP/3 adds another layer. It runs over QUIC instead of TCP, offering better performance on lossy networks, but it requires explicit opt-in via an experimental package.

Understanding how Go handles protocol selection helps you debug silent failures, tune performance, and decide when to stick with the defaults or reach for advanced configuration.

How Go picks the protocol

HTTP/2 and HTTP/1.1 share the same port. The client and server decide which version to use during the TLS handshake. This mechanism is called ALPN, or Application Layer Protocol Negotiation.

The client sends a list of supported protocols in the TLS ClientHello. The server replies with the best match. If both sides support h2, the connection becomes HTTP/2. If the server only supports http/1.1, the connection falls back to HTTP/1.1. The application code doesn't need to change. The net/http server detects the negotiated protocol and routes requests accordingly.

Go enforces a strict rule: HTTP/2 requires TLS. The standard library does not support HTTP/2 over cleartext TCP by default. This design choice simplifies the implementation and aligns with security best practices. If you call http.ListenAndServe without TLS, you get HTTP/1.1 only. If you call http.ListenAndServeTLS, you get HTTP/1.1 and HTTP/2.

HTTP/3 is different. It runs over QUIC, which is built on UDP. QUIC handles encryption and multiplexing at the transport layer. HTTP/3 support in Go lives in the golang.org/x/net/http3 package. This package is experimental. The API may change between releases. You must import it explicitly and use its server functions.

The default server

Here's the standard server. It supports HTTP/2 without any extra code.

package main

import (
	"fmt"
	"net/http"
)

// main starts a server that handles HTTP/1.1 and HTTP/2 automatically.
func main() {
	// Register a handler that prints the protocol version.
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// r.Proto contains the protocol string like "HTTP/2.0" or "HTTP/1.1".
		fmt.Fprintf(w, "Hello via %s\n", r.Proto)
	})

	// ListenAndServeTLS enables HTTP/2 support via ALPN negotiation.
	// The server listens on port 8443 and serves HTTPS.
	err := http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", nil)
	if err != nil {
		panic(err)
	}
}

The handler receives an http.Request. The r.Proto field tells you which protocol the client used. This is useful for logging or conditional logic. The handler signature is the same for HTTP/1.1, HTTP/2, and HTTP/3. Go abstracts the transport details.

The http.ListenAndServeTLS function sets up the TLS listener and configures the ALPN extension. When a client connects, the TLS handshake negotiates the protocol. If the client supports HTTP/2, the server switches to the HTTP/2 frame layer. Requests arrive as streams on a single connection. The net/http server demultiplexes the streams and calls your handler for each request.

HTTP/2 is automatic. If you use TLS, you get it. If you don't, you don't.

HTTP/2 without TLS

Some internal services run without TLS. You might want HTTP/2 performance benefits even on cleartext connections. This mode is called h2c, or HTTP/2 Cleartext. Go supports h2c, but you have to enable it manually.

The standard net/http server does not speak h2c. You need to import golang.org/x/net/http2 and configure the server.

package main

import (
	"log"
	"net/http"

	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"
)

// main starts a server that supports HTTP/2 over cleartext TCP.
func main() {
	// Create a standard HTTP server.
	server := &http.Server{Addr: ":8080"}

	// Configure the server to support HTTP/2 frames.
	// This adds h2c support to the underlying net/http server.
	http2.ConfigureServer(server, nil)

	// Wrap the default mux with h2c so it can speak HTTP/2 without TLS.
	// h2c.NewHandler detects the upgrade request and switches protocols.
	handler := h2c.NewHandler(server.Handler, &http2.Server{})

	// ListenAndServe uses plain TCP. The handler upgrades to HTTP/2 when needed.
	err := server.Serve(handler)
	if err != nil {
		log.Fatal(err)
	}
}

The h2c.NewHandler wrapper intercepts incoming connections. If the client sends an HTTP/2 upgrade request, the handler switches to the HTTP/2 protocol. Otherwise, it falls back to HTTP/1.1. This pattern is common in internal microservices where TLS adds overhead but HTTP/2 multiplexing is valuable.

Convention aside: the http2 package is part of golang.org/x/net, not the standard library. The standard library imports it internally for TLS support, but you must import it explicitly for h2c. This separation keeps the standard library stable while allowing HTTP/2 to evolve.

HTTP/3 support

HTTP/3 runs over QUIC. QUIC is a modern transport protocol that solves head-of-line blocking at the packet level. If a packet is lost, only the affected stream stalls. Other streams continue. QUIC also speeds up connection setup and supports connection migration when the client changes networks.

Go's HTTP/3 support is in golang.org/x/net/http3. You use http3.ListenAndServeQUIC to start a server.

package main

import (
	"fmt"
	"log"
	"net/http"

	"golang.org/x/net/http3"
)

// main runs an HTTP/3 server using the experimental package.
func main() {
	// Create a standard handler mux.
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// HTTP/3 requests arrive here just like HTTP/2 requests.
		fmt.Fprintf(w, "Hello via HTTP/3\n")
	})

	// ListenAndServeQUIC sets up the QUIC listener and HTTP/3 server.
	// It requires TLS certificates because QUIC is encrypted by design.
	err := http3.ListenAndServeQUIC(":443", "cert.pem", "key.pem", mux)
	if err != nil {
		log.Fatal(err)
	}
}

The http3 package provides a server that speaks QUIC and HTTP/3. It uses the same http.Handler interface as the standard library. You can reuse your existing handlers. The package handles the QUIC handshake and HTTP/3 framing.

HTTP/3 is experimental. The API may change. Use it when you need the performance benefits and are willing to track updates. Most production systems still rely on HTTP/2. HTTP/3 is maturing, but it's not yet the default.

HTTP/3 is experimental. Opt in when you need the performance, not because it's shiny.

Debugging and disabling

Sometimes HTTP/2 causes problems. A buggy proxy might drop frames. An old client might hang on multiplexed streams. You can disable HTTP/2 using the GODEBUG environment variable.

# Disable HTTP/2 for client and server to debug compatibility issues.
export GODEBUG=http2client=0,http2server=0

Setting http2client=0 disables HTTP/2 negotiation in the client. The client falls back to HTTP/1.1. Setting http2server=0 disables HTTP/2 on the server. The server rejects HTTP/2 upgrades and serves HTTP/1.1 only. This is useful for isolating protocol-related bugs.

You can also enable debug logging. Set http2debug=1 to see detailed HTTP/2 frame information. This helps when troubleshooting stream resets or flow control issues.

# Enable HTTP/2 debug logging for detailed frame traces.
export GODEBUG=http2debug=1

The GODEBUG flags affect the entire process. Use them for testing or temporary debugging. Don't rely on them for permanent configuration. If you need to disable HTTP/2 for specific clients, configure the transport or server explicitly.

Pitfalls and runtime behavior

HTTP/2 introduces new failure modes. Streams can be reset independently. A slow request on one stream doesn't block others, but it can consume flow control windows. If a stream stalls, the client might cancel it. Your handler receives a cancellation signal via the request context.

Always check the context. The http.Request carries a context that reflects the stream state. If the client cancels the request, the context is done.

// handler checks the context to handle client cancellation.
func handler(w http.ResponseWriter, r *http.Request) {
	// r.Context() is cancelled if the client closes the stream.
	// Long-running operations should select on ctx.Done().
	ctx := r.Context()

	select {
	case <-ctx.Done():
		// The client cancelled the request. Stop processing.
		return
	default:
		// Continue processing.
	}
}

The compiler rejects code that ignores errors. If you forget to check ctx.Err(), you might leak goroutines. The convention is to pass context.Context as the first parameter to functions that can be cancelled. Name it ctx. Respect deadlines and cancellation.

Another pitfall is header compression. HTTP/2 uses HPACK to compress headers. If you send large headers, the compression table grows. The server can send a SETTINGS_HEADER_TABLE_SIZE frame to limit memory usage. The http2 package handles this automatically, but be aware of header size limits.

Goroutine leaks happen when a handler spawns a goroutine that waits on a channel or context that never resolves. Always ensure background goroutines have a cancellation path. The worst goroutine bug is the one that never logs.

Decision matrix

Use net/http.ListenAndServeTLS when you need a standard web server with automatic HTTP/2 support.

Use GODEBUG=http2client=0 when a downstream service breaks with HTTP/2 frames and you need to force HTTP/1.1 for debugging.

Use golang.org/x/net/http3 when you need HTTP/3 performance benefits like connection migration or reduced latency on lossy networks.

Use http2.ConfigureServer with h2c.NewHandler when you must support HTTP/2 over cleartext TCP for internal microservices that cannot use TLS.

Use a plain http.ListenAndServe when you are building a simple tool that only needs HTTP/1.1 and wants to avoid TLS complexity.

Trust the negotiation. Let the client and server agree on the best protocol.

Where to go next