HTTP/2 in Go: You're Still on HTTP/1.1 and It's Costing You
Meta: "Go supports HTTP/2 automatically but only under specific conditions most teams miss. Here's what's actually running in your service, and how to check."
T
he mobile team had done everything right. Indexed the database. Added Redis in front of the hot reads. Profiled every handler and cut individual endpoint P99 down to under 20ms. On paper, the service was fast.On a phone in Jakarta, loading a product page still felt slow.
The product page made 15 API calls. Each one completed in under 20ms in isolation. Together, under real mobile conditions, the user waited 800ms before the page finished rendering. The math didn't add up and nobody had stopped to ask what HTTP version the service was actually speaking.
HTTP/1.1. It had always been HTTP/1.1. The team assumed Go's HTTP server was modern by default. It is but only under specific conditions nobody had checked.
What HTTP/1.1 is actually doing to your connections
HTTP/1.1 has a fundamental problem no amount of tuning fixes: one request at a time, per connection.
With keep-alive which most modern clients use the connection stays open between requests. Better than the original model where every request opened and closed a TCP connection. But the requests still queue. Request A is in flight, request B waits. If you have 15 API calls to make, you're serializing them through a narrow pipe, or you're opening 15 separate connections each with its own TCP handshake and TLS negotiation.
This is head-of-line blocking. A slow response a database query taking 80ms, a downstream service with a hiccup blocks everything behind it on that connection. Browsers work around this by opening 6 connections per origin. Mobile clients do similar things. You're now paying TCP and TLS setup costs 6 times per burst, burning file descriptors, and multiplying connection state across your API fleet.
On a low-latency internal network, this overhead is background noise. On a mobile connection from Surabaya to an API server, where a TCP round-trip is 30ms and TLS adds another 60ms per new connection, it's a meaningful chunk of your total response time.
What HTTP/2 actually changes
HTTP/2 runs multiple requests and responses simultaneously over a single TCP connection. Streams, not queues. Request A and request B go out at the same time. Their responses come back as fast as the server produces them, interleaved on the wire, reassembled at the receiver.
Head-of-line blocking disappears at the application layer. (TCP still has it HTTP/3 on QUIC removes that too, which is a separate conversation.)
Headers get compressed. HTTP/1.1 sends full header strings on every request. A typical API call carries 400-800 bytes Authorization, Content-Type, Accept, User-Agent, cookies. HTTP/2 uses HPACK compression, which deduplicates headers seen in previous requests on the same connection. After the first request, repeated headers compress to a handful of bytes. A service handling 10,000 requests per second with 600-byte average headers is pushing 6MB/s of header data in HTTP/1.1. With HPACK, after warmup, that's closer to 0.5MB/s.
HTTP/2 also uses binary framing instead of text. HTTP/1.1 is parsed character by character until the server finds \r\n\r\n. HTTP/2 sends fixed-size binary frames faster to parse, less error-prone to produce.
For high-throughput APIs, the multiplexing alone changes the concurrency model. Instead of connection pools sized for burst parallelism, a single HTTP/2 connection handles the concurrency. Fewer sockets, fewer file descriptors, less TLS overhead per burst.
Go's HTTP/2 support and the condition everyone misses
Go's net/http has supported HTTP/2 since Go 1.6. Automatic. No configuration.
There's a condition: you have to be running TLS.
Untitled1// HTTP/2 enabled automatically ALPN negotiates it during TLS handshake2http.ListenAndServeTLS(":443", "cert.pem", "key.pem", mux)
Use ListenAndServeTLS and Go negotiates HTTP/2 via ALPN during the TLS handshake. Clients that support HTTP/2 basically everything made after 2016 get it automatically. This is Go's default behavior and it works well.
Plain HTTP is different. http.ListenAndServe gives you HTTP/1.1. Always. Even if the client sends an HTTP/2 upgrade header, Go's standard server ignores it. The spec calls plaintext HTTP/2 "h2c" (HTTP/2 cleartext), and Go doesn't enable it by default because it requires explicit opt-in and is genuinely less common in practice.
This matters for internal service-to-service calls. Your microservices probably communicate over plain HTTP inside a VPC or Kubernetes cluster no TLS between services on a trusted network. If you assumed those connections were HTTP/2, they weren't. They've been HTTP/1.1 the whole time.
Enabling HTTP/2 for plain HTTP (h2c)
If you're running without TLS and want HTTP/2 for internal traffic, you need the h2c handler from golang.org/x/net:
Untitled1import (2 "net/http"3 "golang.org/x/net/http2"4 "golang.org/x/net/http2/h2c"5)67func main() {8 mux := http.NewServeMux()9 mux.HandleFunc("/", yourHandler)1011 h2Server := &http2.Server{}12 handler := h2c.NewHandler(mux, h2Server)1314 http.ListenAndServe(":8080", handler)15}
That's the server. The client also needs explicit configuration:
Untitled1import (2 "context"3 "crypto/tls"4 "net"5 "net/http"6 "golang.org/x/net/http2"7)89transport := &http2.Transport{10 AllowHTTP: true,11 DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {12 return net.Dial(network, addr)13 },14}1516client := &http.Client{Transport: transport}
More verbose than it should be. Go's position is that h2c is a niche case most production traffic uses TLS, even internally. For teams using a service mesh with mTLS at the sidecar layer (Istio, Linkerd), h2c is the right move. The mesh handles TLS; your service code handles h2c.
The part most teams get wrong
Two mistakes show up repeatedly.
The first: assuming the load balancer handles HTTP/2 end-to-end. Most load balancers ALB, nginx, Caddy terminate HTTP/2 from the client and forward HTTP/1.1 to the backend. The client-to-LB leg gets HTTP/2 and its benefits. The LB-to-service leg is HTTP/1.1. If your latency problem is between services, the HTTP/2 at the edge doesn't help you there.
AWS ALB supports HTTP/2 to targets but you have to enable it explicitly in target group settings. Most people don't. Check your observability layer for the protocol version on backend requests don't assume.
The second mistake: switching to HTTP/2 expecting faster handlers. HTTP/2 reduces connection overhead. It doesn't speed up what your handlers do. If the service is slow because of expensive computation, a database under load, or misconfigured connection pools, HTTP/2 does nothing. The protocol makes connection management cheaper. It doesn't make slow work fast.
And if your services already use gRPC this conversation is over. gRPC runs on HTTP/2. You're already multiplexing, already using HPACK, already getting connection efficiency. Nothing to do.
How to check what your service is actually running
Before changing anything, verify the current state.
Untitled1# Check if server negotiates HTTP/2 over TLS2curl -v --http2 https://your-service.example.com/health34# Test HTTP/2 over plaintext (h2c)5curl -v --http2-prior-knowledge http://your-service.example.com/health
Look for < HTTP/2 200 in the output. < HTTP/1.1 200 means you're on HTTP/1.1.
On the client side, Go's http.Client uses HTTP/2 transparently when talking to a TLS server that supports it. You can verify:
Untitled1resp, _ := client.Get("https://your-service.example.com/health")2fmt.Println(resp.Proto) // "HTTP/2.0" or "HTTP/1.1"
In pprof, you won't see "HTTP version" directly. But high goroutine counts on (*connReader).readLoop with many separate connections in your network traces is the HTTP/1.1 connection-per-burst pattern. That's the thing to look for when you're debugging connection overhead under load.
What it looks like at Indonesian traffic scale
Traveloka's mobile app, at peak, makes a dozen concurrent API calls to render a single search results page flights, hotels, promotions, user preferences, pricing. During Lebaran, that baseline multiplies.
Under HTTP/1.1, each user session opens multiple parallel connections. At 100,000 concurrent users making 6 connections each, that's 600,000 open sockets. TCP state accumulates. File descriptor limits become real. TIME_WAIT backlogs build. The infrastructure cost of all those connection lifetimes adds up in ways that don't show up on individual endpoint latency metrics they show up as connection errors, pod restarts, and on-call pages at 2am.
HTTP/2 collapses that. One connection per client. All 12 requests multiplexed over it. Connection count drops by 6x. TLS handshake cost paid once per session instead of once per burst. At real Indonesian traffic scale, this is an operational cost and a reliability story, not a benchmark number.
You don't need Traveloka's traffic to feel the difference. 10,000 concurrent users, 6 HTTP/1.1 connections each: 60,000 open connections. HTTP/2: 10,000. Same product behavior. The rest is connection overhead you were paying for nothing.
FAQ
Q: Does Go enable HTTP/2 automatically?
A: Yes, but only over TLS. ListenAndServeTLS triggers automatic HTTP/2 negotiation via ALPN no configuration needed. ListenAndServe (plain HTTP) stays on HTTP/1.1 regardless of what the client requests. For plaintext HTTP/2, use golang.org/x/net/http2/h2c.
Q: Will HTTP/2 make my individual API requests faster? A: No. HTTP/2 reduces connection overhead and enables concurrent requests over one connection. Processing time per request is unchanged. The benefit shows up when many requests run in parallel fewer connections, less TLS overhead, less file descriptor pressure under burst load.
Q: Should I use HTTP/2 for service-to-service calls inside Kubernetes? A: It depends on your setup. With a service mesh doing mTLS at the sidecar layer, you can enable HTTP/2 at the mesh level without touching application code. Without a mesh, h2c requires explicit server and client configuration. Teams already using gRPC internally don't need to do anything gRPC is HTTP/2.
Q: Does my load balancer handle HTTP/2 end-to-end? A: Probably not by default. AWS ALB supports HTTP/2 to targets but requires explicit configuration in target group settings. nginx supports it via upstream configuration. Many setups terminate HTTP/2 at the LB and forward HTTP/1.1 to backends check your config before assuming.
Q: How does HTTP/2 compare to HTTP/3?
A: HTTP/2 fixes head-of-line blocking at the application layer, but TCP's packet-loss behavior still serializes streams. HTTP/3 runs on QUIC over UDP, removing TCP-level head-of-line blocking too. Go has experimental HTTP/3 support via github.com/quic-go/quic-go. Worth watching; not ready for most production services today.
HTTP/2 in Go is well-supported, often already running if you're using TLS, and genuinely useful under concurrent mobile load. It's not a fix for slow handlers or database bottlenecks. It's a fix for connection overhead and at scale, connection overhead is one of those costs that hides until it suddenly isn't hidden anymore.
Check what protocol your service is actually speaking. It takes two minutes with curl. If you're on HTTP/1.1 and taking mobile traffic with parallel requests, the upgrade is low-risk and the connection savings are real. If you're already on HTTP/2 and still slow, go look at your database.
Internal links used:
- high-throughput APIs Pillar #4, anchored to HTTP/2 concurrency context
- observability layer E-7, checking protocol version in production traces
- gRPC L-1, correct exit: gRPC users already on HTTP/2
External links used:
- None technical claims sourced from Go documentation and IETF HTTP/2 spec (RFC 7540)
Word count: ~1,840