Here, removing head-of-line blocking means removing HTTP response-order constraints on a shared connection. HTTP/2 does not remove TCP head-of-line blocking. A later response can finish while an earlier handler waits, but shared transport capacity, connection-level flow control, and application bottlenecks can still couple the streams.
Separate the two queues
HTTP/1.1 already supports persistent connections, so it is not one connection per request. Requests can reuse a socket sequentially. Pipelining permits multiple outstanding requests, and a server may process safe methods concurrently, but responses on that connection must remain in request order. A slow first response therefore delays delivery of later responses. Clients often use multiple connections instead.
HTTP/2 assigns stream identifiers to frames and interleaves messages on the connection. The server can send a ready response without first finishing an unrelated response. This is independent progress at the HTTP layer, not a promise that handlers receive separate CPU cores, databases, bandwidth, or unlimited scheduling opportunities.
TCP still presents one ordered byte stream. A missing byte range can prevent later received bytes, including frames for other HTTP/2 streams, from reaching the application until recovery fills the gap. Previously delivered bytes are not withdrawn. QUIC provides ordered bytes within each stream without requiring ordering across streams; it does not make ordinary stream data unordered or remove shared congestion and flow-control budgets.
Concurrency still has budgets
The peer’s SETTINGS_MAX_CONCURRENT_STREAMS limits active streams. DATA frames also consume both stream-level and connection-level flow-control credit. Separate stream windows do not imply unlimited independence: exhausted connection credit can stop DATA on other streams too. Receiver consumption and window-update policy matter, as do shared worker pools, locks, and upstream connections.
Inspect the negotiated protocol on the hop being measured. Browser-to-proxy HTTP/2 does not establish proxy-to-origin HTTP/2. A successful TLS connection alone is also insufficient evidence. In Go, a custom TLS configuration can require ForceAttemptHTTP2 to attempt HTTP/2; the response protocol and negotiated h2 value establish the actual result.
When it breaks
A fast request completing first proves little if the client opened another TCP connection. Conversely, a single-connection HTTP/1.1 client can queue requests before the server ever sees them. Count accepted connections and inspect client acquisition events before attributing order to server scheduling or network loss.
Increasing stream concurrency cannot repair an overloaded database or a connection window starved by unread data. Track response-body completion, active streams, shared resource waits, and per-hop protocol. Do not label every long request TCP head-of-line blocking, and do not assume a protocol upgrade alone supplies a measured page-speed improvement.
Check it quickly
Save this as queue_probe.go and run go run queue_probe.go with Go 1.23 or newer. It creates separate local HTTP/1.1 and HTTP/2 fixtures over verified TLS 1.3. Each transport permits one TCP connection. A channel holds the slow handler, while GetConn confirms the fast request has begun trying to acquire a connection.
package main
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"runtime"
"sync"
"sync/atomic"
"time"
)
type connectionKey struct{}
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func exercise(h2 bool) {
slowEntered, releaseSlow, stop := make(chan struct{}), make(chan struct{}), make(chan struct{})
var connections atomic.Int64
var slowPassed atomic.Bool
var orderMu sync.Mutex
var order []string
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/slow" {
close(slowEntered)
select {
case <-releaseSlow:
slowPassed.Store(true)
case <-r.Context().Done():
return
case <-stop:
return
}
}
orderMu.Lock()
order = append(order, r.URL.Path)
orderMu.Unlock()
w.Header().Set("X-Connection-ID", fmt.Sprint(r.Context().Value(connectionKey{})))
fmt.Fprint(w, r.URL.Path)
}))
server.Config.ConnContext = func(ctx context.Context, _ net.Conn) context.Context {
return context.WithValue(ctx, connectionKey{}, connections.Add(1))
}
server.EnableHTTP2 = h2
server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
server.StartTLS()
defer server.Close()
defer close(stop)
config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
check(!config.InsecureSkipVerify, "certificate verification must stay enabled")
transport := &http.Transport{TLSClientConfig: config, ForceAttemptHTTP2: h2, MaxConnsPerHost: 1}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
protocol := "HTTP/1.1"
if h2 {
protocol = "HTTP/2.0"
}
call := func(route string, trace *httptrace.ClientTrace) string {
request, err := http.NewRequest(http.MethodGet, server.URL+route, nil)
must(err)
if trace != nil {
request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
}
response, err := client.Do(request)
must(err)
body, readError := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.StatusCode == 200 && response.Proto == protocol, "wrong HTTP response or protocol")
check(response.Header.Get("X-Connection-ID") == "1", "requests must share the first TCP connection")
check(response.TLS.Version == tls.VersionTLS13 && len(response.TLS.VerifiedChains) > 0, "verified TLS 1.3 required")
if h2 {
check(response.TLS.NegotiatedProtocol == "h2", "HTTP/2 must be negotiated")
}
return string(body)
}
slowDone, fastDone := make(chan string, 1), make(chan string, 1)
go func() { slowDone <- call("/slow", nil) }()
<-slowEntered
fastAttempted := make(chan struct{}, 1)
trace := &httptrace.ClientTrace{GetConn: func(string) {
select {
case fastAttempted <- struct{}{}:
default:
}
}}
go func() { fastDone <- call("/fast", trace) }()
<-fastAttempted
wantFirst := "/slow"
if h2 {
check(<-fastDone == "/fast" && !slowPassed.Load(), "fast stream must finish while slow is gated")
close(releaseSlow)
check(<-slowDone == "/slow", "wrong slow response")
wantFirst = "/fast"
} else {
close(releaseSlow)
check(<-slowDone == "/slow" && <-fastDone == "/fast", "wrong sequential responses")
}
orderMu.Lock()
defer orderMu.Unlock()
check(len(order) == 2 && order[0] == wantFirst && order[1] != wantFirst, "wrong handler response order")
check(connections.Load() == 1, "fixture must use exactly one TCP connection")
fmt.Println(protocol, order, "one TCP connection")
}
func main() {
exercise(false)
exercise(true)
fmt.Println("gated ordering test; no packet-loss or speedup measurement")
fmt.Println(runtime.Version())
}
The HTTP/1.1 run prints [/slow /fast]; the HTTP/2 run prints [/fast /slow]. Both assert exactly one accepted TCP connection, complete response bodies, the expected HTTP version, and validated TLS. HTTP/2 additionally asserts h2 negotiation and that the fast body finishes before the slow handler is released.
The control is specifically Go’s non-pipelining HTTP/1.1 transport under a one-connection cap. It does not demonstrate that all HTTP/1.1 servers must execute pipelined requests serially. The gate is released at different points: after the fast acquisition attempt for HTTP/1.1, but only after fast completion for HTTP/2. Consequently, this is a controlled ordering experiment, not a fair latency benchmark.
All assertions passed on macOS with Go 1.27.0. Each request has a two-second timeout; cleanup releases waiting handlers and closes local resources. There is no injected packet loss, exhausted flow-control window, browser waterfall, or throughput measurement. The TCP and QUIC distinctions above come from the standards, not from unperformed network experiments.
Further reading
Sources
- RFC 9112: HTTP/1.1, persistence and pipelined response ordering, checked 2026-09-09.
- RFC 9113: HTTP/2, multiplexing, TCP limitations, stream concurrency, and two-level flow control, checked 2026-09-09.
- RFC 9000: QUIC, ordered stream delivery and shared transport limits, checked 2026-09-09.
- Go net/http, transport connection limits and HTTP/2 configuration, checked 2026-09-09.
- Go net/http/httptrace, acquisition events and their scope, checked 2026-09-09.
- Go net/http/httptest, local TLS fixtures and HTTP/2 setup, checked 2026-09-09.