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 other streams’ HTTP/2 frames, from reaching the application until recovery fills the gap. Previously delivered bytes are not withdrawn. HTTP/3 uses QUIC: each stream’s bytes remain ordered without a cross-stream transport delivery order. Shared congestion and flow-control limits, application dependencies, and QPACK header dependencies still matter; this is separate protocol context, not an HTTP/3 experiment.

Concurrency still has budgets

The peer’s SETTINGS_MAX_CONCURRENT_STREAMS limits streams initiated toward that peer. Nonempty DATA payloads consume both stream-level and connection-level credit, including padding but excluding the frame header. Exhausted connection credit can block other streams’ DATA. An empty DATA frame carrying END_STREAM may still be sent at zero credit; control frames do not consume these windows. Receiver consumption, window updates, worker pools, locks, and upstream connections still matter.

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.

The non-pipelining single-connection HTTP/1.1 fixture queues acquisition; HTTP/2 allows the fast response before slow release. TCP delivery, two DATA credit gates, the empty END_STREAM exception and separate QUIC context follow.
HTTP/2 streams; ordered TCP bytes: conceptual mechanisms and expected checks, not new runtime evidence.

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.

Earlier article text reported success on macOS with Go 1.27.0; the retained exact-source review establishes compilation only, not that historical runtime. This review did not execute the fixture. Requests have two-second client timeouts, but that is not an externally enforced process deadline; a panic in a request goroutine can bypass another goroutine’s deferred cleanup. No packet loss, exhausted window, browser waterfall, or throughput was measured. TCP and QUIC distinctions come from standards.

Further reading

Sources