HTTP/3 is built on top of QUIC, a transport protocol that’s surprisingly more like a faster, more robust TCP than a simple UDP wrapper.

Let’s get a basic HTTP/3 server up and running in Go using quic-go.

First, you’ll need to install the quic-go library:

go get -u github.com/quic-go/quic-go

Now, here’s the server code. We’ll need TLS certificates for QUIC, so we’ll generate some self-signed ones for this example.

package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"log"
	"net/http"

	"github.com/quic-go/quic-go"
	"github.com/quic-go/quic-go/http3"
)

func main() {
	// Generate self-signed certificates for TLS
	// In production, use proper certificates from a CA
	cert, err := tls.LoadX509KeyPair("server.pem", "server.key")
	if err != nil {
		log.Fatalf("Failed to load TLS key pair: %v", err)
	}

	config := &tls.Config{
		Certificates: []tls.Certificate{cert},
		NextProtos:   []string{"h3"}, // HTTP/3 protocol identifier
	}

	// Create a QUIC listener
	listener, err := quic.ListenAddr("localhost:8443", config, nil)
	if err != nil {
		log.Fatalf("Failed to create QUIC listener: %v", err)
	}
	defer listener.Close()

	fmt.Println("HTTP/3 server listening on :8443")

	// Create an HTTP/3 server
	server := &http3.Server{
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Printf("Received request: %s %s\n", r.Method, r.URL.Path)
			w.WriteHeader(http.StatusOK)
			w.Write([]byte("Hello, HTTP/3!"))
		}),
		TLSConfig: config,
	}

	// Accept QUIC connections and serve HTTP/3 requests
	for {
		conn, err := listener.Accept(context.Background())
		if err != nil {
			log.Printf("Failed to accept QUIC connection: %v", err)
			continue
		}

		go func() {
			// Serve HTTP/3 on the QUIC connection
			if err := server.ServeQUICConnection(conn); err != nil {
				log.Printf("Failed to serve QUIC connection: %v", err)
			}
		}()
	}
}

// Helper function to generate self-signed certificates (run this once)
// You can use openssl:
// openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.pem -days 365 -nodes -subj '/CN=localhost'

To generate the server.pem and server.key files, you can use openssl:

openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.pem -days 365 -nodes -subj '/CN=localhost'

When you run the Go program, it starts a QUIC listener on UDP port 8443. The http3.Server from quic-go then handles the HTTP/3 framing on top of the established QUIC connections. The NextProtos: []string{"h3"} in the tls.Config is crucial; it tells the client and server that we’re negotiating HTTP/3.

The core of the quic-go HTTP/3 implementation is its http3.Server type. This server wraps a standard http.Handler and translates HTTP/3 streams into the familiar http.Request and http.ResponseWriter interfaces. When a new QUIC connection is accepted by listener.Accept, we spin up a goroutine to call server.ServeQUICConnection(conn). This function manages the HTTP/3 streams multiplexed over that single QUIC connection.

The most surprising thing about HTTP/3 is that it doesn’t mandate HTTP/2 features like header compression (HPACK) but instead uses a new, more efficient method called QPACK. This is designed to avoid the HOL blocking issues that can plague HPACK in the presence of packet loss, a common problem with TCP-based HTTP/2.

To test this, you’ll need a browser that supports HTTP/3 (like a recent version of Chrome or Firefox) or a tool like curl compiled with HTTP/3 support. You’d typically access it via https://localhost:8443.

The next hurdle you’ll likely encounter is handling multiple HTTP/3 requests concurrently and managing their lifecycle efficiently, especially when dealing with connection migration and stream cancellation.

Want structured learning?

Take the full Http3 course →