HTTP/2 can run over a cleartext connection using the h2c upgrade mechanism, and it’s surprisingly useful for internal services where TLS overhead isn’t strictly necessary for security.
Let’s watch a request go from a client to a server over h2c.
Client (curl):
curl --http2-prior-knowledge -v http://localhost:8080/
Server (example Go handler):
package main
import (
"fmt"
"log"
"net/http"
"golang.org/x/net/http2"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, HTTP/2 over h2c!")
log.Printf("Request received: %+v", r.Proto)
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Enable HTTP/2 without TLS
http2.ConfigureServer(server, &http2.Server{})
log.Println("Starting server on :8080 with HTTP/2 over h2c")
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
When curl makes the request, it includes an Upgrade: h2c header. The server, if configured for h2c, responds with a 101 Switching Protocols status and the connection is upgraded to HTTP/2. The subsequent frames on this connection will be HTTP/2 frames.
The key advantage of h2c for internal services is performance. Eliminating TLS encryption and decryption removes a significant CPU burden. For high-volume, low-latency internal APIs, this can translate to measurable improvements in throughput and reduced latency. Furthermore, it simplifies configuration and certificate management for services that are already protected by network-level security (e.g., firewalls, private networks).
Here’s how it works under the hood:
- Client Initiation: The client sends a standard HTTP/1.1 request. Crucially, it includes the
Upgrade: h2cheader. - Server Handshake: If the server is configured to support
h2c, it recognizes theUpgradeheader. It responds with an HTTP101 Switching Protocolsstatus code. - Connection Upgrade: After the
101response, the connection is no longer an HTTP/1.1 connection. It’s now an HTTP/2 connection, and both client and server will use HTTP/2 framing. - HTTP/2 Framing: All subsequent communication on this established connection uses HTTP/2’s binary framing protocol, enabling features like multiplexing, header compression (HPACK), and server push, but without the TLS overhead.
The http2.ConfigureServer function in Go is the critical piece for enabling this. You pass it a standard http.Server configuration and a http2.Server struct. This tells the Go HTTP server to listen for the Upgrade: h2c header and perform the handshake.
For the client, curl --http2-prior-knowledge is essential. It tells curl to immediately attempt an HTTP/2 connection using h2c on the specified port, bypassing the standard HTTP/1.1 upgrade dance if it knows the server supports it. If not, it will fall back to the upgrade mechanism.
The most surprising thing about h2c is how rarely it’s used, despite the clear performance benefits for internal networks where TLS is often redundant due to existing network segmentation and security controls. Many teams assume HTTP/2 requires TLS, leading them to unnecessarily encrypt internal traffic.
You’ll often find h2c used in conjunction with reverse proxies like Nginx or Envoy. The proxy might terminate TLS from external clients and then communicate with internal services using h2c to reduce latency and CPU usage within the cluster.
The primary configuration lever you have is on the server-side, ensuring your HTTP server is explicitly configured to enable HTTP/2 support, and then specifically enabling the h2c upgrade mechanism. On the client side, it’s about signaling intent, either through the Upgrade header or by using client flags that indicate a preference for HTTP/2 over cleartext.
The next hurdle you’ll likely face is handling load balancing for services using h2c, as not all load balancers natively understand HTTP/2 upgrade requests.