APIs & Networking
Connection reuse in HTTP/1.1, HTTP/2 and HTTP/3: how each works and where each breaks
How HTTP/1.1, HTTP/2 and HTTP/3 reuse connections, where each one breaks, and which to use for calls between services.
By Mira Vance
Published 26 October 2026 · Last reviewed 26 October 2026

A four-line Go function that fetches a URL and returns only its status code, called 100 times over HTTP/1, took about 710 milliseconds on Go 1.26 and about 150 milliseconds on Go 1.27, on one machine, in the Go team's own test against index.golang.org. The function did not change. Go 1.27's net/http now drains an unread HTTP/1 response body in the background, for up to 50 ms or 256 KB, so the connection can be reused instead of thrown away. That fix opens a bigger question for anyone whose backend calls other services: how do HTTP/1.1, HTTP/2 and HTTP/3 each reuse a connection, how does each one fail, and which should you use? We answer the same questions for all three, from the specifications.
Drafted with AI assistance, researched, verified and edited by the HastCode editorial team. We publish only as many pieces as the Author can verify this way.
TL;DR
• HTTP/1.1 reuses a connection for one exchange at a time. Connections persist by default, but the client must read each response body to the end before the next request, so concurrency means a pool of connections.
• HTTP/2 carries many concurrent requests as streams on one TCP connection per host. The limits move to the server's stream cap and to TCP itself, where one lost packet stalls every stream.
• HTTP/3 does the same over QUIC, where a lost packet stalls only the streams it carried, and connection IDs let a connection survive a client address change.
• For service-to-service calls, we infer: HTTP/2 is the default to reach for between services you control; HTTP/1.1 with a pool sized to your concurrency is fine for short requests; HTTP/3 pays off on lossy or changing paths, and Go's net/http does not speak it.
Three ways to reuse one connection
Reuse exists because a new connection is expensive. The Go blog post counts it out: a TCP handshake costs 1 round trip, a TLS 1.2 handshake 2 more, so the first request leaves after 3 round trips, or 300 ms of setup at a 100 ms cross-region round trip. QUIC folds the cryptographic and transport handshakes into one to minimise connection establishment latency, but a reused connection skips the handshake entirely in all three protocols.
The diagram shows what "reuse" means in each. HTTP/1.1 keeps a pool of connections, each carrying one exchange at a time. HTTP/2 puts every request on its own stream inside one TCP connection, with odd-numbered client stream IDs. HTTP/3 does the same inside one QUIC connection over UDP, with client requests on streams 0, 4, 8 and so on.
HTTP/1.1: one exchange at a time, many connections
RFC 9112 makes connections persistent by default: a connection carries request after request until either side sends Connection: close. There is a catch in the same section: "a client MUST read the entire response message body if it intends to reuse the same connection for a subsequent request." The reason is that HTTP/1.1 has no request identifier; responses are matched to requests purely by arrival order on the connection.
That is exactly the trap the Go post describes. Through Go 1.26, closing a response body that had not been read to the end made net/http abandon the connection and dial a new one. Even code that reads the body can miss: encoding/json.Decoder stops at the closing brace, so a trailing newline left a healthy connection, in Nicholas Husin and Damien Neil's words, "a single byte away from being reusable."
Pipelining lets a client send several requests without waiting, but the server must answer in order, and RFC 9113 says pipelining only partially addressed request concurrency and still suffers from application-layer head-of-line blocking. In practice, clients open several connections per server for concurrency, and RFC 9112 asks them to be conservative without naming a maximum.
In Go, the Transport caches connections for reuse, keeping at most MaxIdleConnsPerHost idle per host, which defaults to 2.
HTTP/2: one connection, many streams
HTTP/2 gives each request and response its own stream inside one TCP connection, and frames from different streams interleave. Streams are largely independent, "so a blocked or stalled request or response does not prevent progress on other streams," as the RFC 9113 overview puts it. That is why the Go post notes that an unread body on one HTTP/2 stream does not block other requests, and that draining HTTP/2 bodies is unnecessary.
Reuse is the design, not an optimisation. HTTP/2 connections are persistent, and clients should not open more than one per host and port. A client opens a replacement only when the old one is close to running out of stream IDs, needs fresh TLS keys, or has hit an error. Stream IDs cannot be reused, so a long-lived connection does eventually run out.
Sharing one connection needs a way to stop one stream hogging it. HTTP/2 has credit-based flow control per stream and per connection, starting at 65,535 octets each.
In Go, the Transport uses HTTP/1.1 for http:// URLs and HTTP/1.1 or HTTP/2 for https:// URLs, depending on the server and the configuration. The DefaultTransport attempts HTTP/2, but a Transport you build yourself with Protocols left nil is usually HTTP/1 only unless ForceAttemptHTTP2 is set. Plaintext HTTP/2 needs Protocols set to unencrypted HTTP/2 without HTTP/1.
HTTP/3: QUIC streams that survive a new address
HTTP/3 maps each request-response pair onto one QUIC stream. QUIC itself provides the multiplexing and per-stream flow control that HTTP/2 had to build on top of TCP, and it carries TLS 1.3 inside the transport. Reuse follows HTTP/2's rules: connections are persistent, and a client should not open more than one per IP address and UDP port.
What is new is that the connection is not tied to an address. A QUIC connection has a set of connection IDs, chosen so that changes at the UDP and IP layers do not deliver its packets to the wrong endpoint. That lets a connection survive a change of address and port, whether the client moves on purpose or a NAT rebinds it. Only clients start a migration, never before the handshake is confirmed, and a server can forbid active migration with the disable_active_migration transport parameter.
Two limits matter for a backend. First, UDP can be blocked, and RFC 9114 tells clients to fall back to TCP-based HTTP when a QUIC connection fails. Second, Go's net/http supports HTTP/1.0, HTTP/1.1 and HTTP/2, and its Transport documentation names only HTTP/1.1 and HTTP/2. We infer that a Go service calling over HTTP/3 needs a client from outside the standard library.
Head-of-line blocking moves down a layer each time
Head-of-line blocking means one slow item holds up everything queued behind it. Each protocol fixes it at one layer and meets it at the next.
HTTP/1.1 blocks at the application layer. RFC 9112 says clients open extra connections to avoid exactly this: a slow or very large response blocks the requests behind it on the same connection. An unread body blocks reuse the same way.
HTTP/2 removes that by interleaving streams, but every stream shares one TCP byte stream. RFC 9113 says so plainly: "TCP head-of-line blocking is not addressed by this protocol." RFC 9114 spells out the cost: because TCP cannot see HTTP/2's streams, "a lost or reordered packet causes all active transactions to experience a stall" whether or not they lost data.
HTTP/3 moves reliability to the stream. In QUIC, only streams with data in a lost packet wait for its retransmission; the rest keep going. Two caveats from the same specifications: a packet that carries data from several streams blocks all of them when lost, and within one stream, bytes are still delivered in order. Header compression also needed care: HTTP/3 uses QPACK, which gives the encoder some control over how much head-of-line blocking compression can cause.
Running out: pools, streams and stream limits
Each protocol has a ceiling on concurrent requests, and each behaves differently at it.
HTTP/1.1 runs out of connections. In Go, MaxConnsPerHost caps dialing, active and idle connections per host, and when it is reached, "dials will block"; zero, the default, means no limit. Without a cap, RFC 9112 warns that many parallel connections can congest the network, and a server may reject them as abusive. With the idle cap at its default of two, we infer that a burst of more than two concurrent requests to one host closes the extra connections once they go idle, and the next burst dials again.
HTTP/2 runs out of streams. A server sets SETTINGS_MAX_CONCURRENT_STREAMS, and a client that exceeds it gets a stream error. Go's StrictMaxConcurrentRequests picks the response: by default the Transport opens another connection when every connection is at its limit; set to true, new requests block until one completes.
HTTP/3 runs out of streams too. QUIC caps the cumulative number of streams a peer may open and raises the cap with MAX_STREAMS frames; a blocked endpoint should say so with STREAMS_BLOCKED. RFC 9114 asks servers to permit at least 100 request streams at a time.
Idle connections and the close race
A reused connection sits idle between requests, and either side may close it.
HTTP/1.1 gives no signal. Servers time out inactive connections at a length the specification leaves open, and RFC 9112 names the race: "a client might have started to send a new request at the same time that the server has decided to close the 'idle' connection." Go's Transport retries such a request only if the connection had been used successfully, the method is idempotent (or carries an Idempotency-Key), and the body can be replayed. We infer that setting the client's IdleConnTimeout below the server's idle timeout makes the race rarer.
HTTP/2 adds a goodbye. A closing endpoint should send GOAWAY with the last stream it may have processed; anything above it was not processed and can be retried on a new connection, even a POST. A PING frame tests an idle connection that a NAT or load balancer may have silently dropped; Go's SendPingTimeout turns that check on.
HTTP/3 negotiates the timeout. Each QUIC endpoint advertises one, and the smaller wins; past it, the connection closes silently. Clients need a new connection once it has passed, and should open one as it approaches, and GOAWAY works as in HTTP/2. RFC 9000 adds that middleboxes can drop UDP state sooner, and that packets every 30 seconds keep most of them from doing so.
Coalescing: one connection for several hostnames
HTTP/2 and HTTP/3 can go further than reuse per host: one connection may carry requests for several origins, as long as the server is authoritative for each. Over TLS, that means the certificate must be valid for every host the client sends on it; without TLS, the hosts must resolve to the same IP address. HTTP/3 has the same rule, and adds that if a certificate fails validation for a reason that could apply to other origins on the connection, such as expiry or revocation, the client should revalidate it for those origins too.
Coalescing can misroute. RFC 9113 gives the case of a middlebox that terminates TLS and picks the backend by Server Name Indication: a request for another hostname can then reach a server that was not its intended target. A server that does not want its connections reused this way answers 421 (Misdirected Request) to say it is not authoritative for that request.
HTTP/1.1 has no such rule: RFC 9112's connection management section does not describe sharing a connection across origins, and Go's pool limits are per host. We infer that coalescing is a concern only once HTTP/2 or HTTP/3 is on, typically when several internal services share one wildcard certificate behind one proxy.
Trade-offs
Each cell summarises a section above, where its source is linked.
When each wins for service-to-service calls
Everything in this section is our inference from the facts above, not a benchmark.
HTTP/2 wins when you control both ends and one service sends many small, concurrent requests to a few others. One connection per host means one handshake, no pool to size, no unread-body trap, and GOAWAY tells the client which requests are safe to retry. Watch two things: a lossy link stalls every stream at once, and one connection gives a TCP load balancer only one backend to pick per client.
HTTP/1.1 wins when requests are short, the peer speaks nothing else, or you call plain http:// inside a cluster, where Go defaults to it. Size MaxIdleConnsPerHost to your real concurrency, close every response body, and upgrade to Go 1.27 so net/http drains what you leave unread.
HTTP/3 wins when the path is lossy or the client's address changes, the cases its stream-level recovery and connection migration were built for. Inside one data centre, with low loss and fixed addresses, we expect it to gain less, and a Go backend would need a client outside the standard library.
The good news is that each step solved a real problem. You can pick the protocol whose remaining failure mode you can live with.
Sources
• Nicholas Husin and Damien Neil, Better HTTP/1 connection reuse, The Go Blog, 14 October 2026 (CC BY 4.0).
• RFC 9112: HTTP/1.1, section 9, Connection Management.
• RFC 9113: HTTP/2, sections 1, 2, 5, 6.8, 8.7 and 9.
• RFC 9114: HTTP/3, sections 1, 2, 3, 4.2.1, 5 and 6.1.
• RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport, sections 2, 4.6, 5, 7, 9, 10.1 and 13.
• Go standard library, net/http package documentation (Transport, HTTP2Config, DefaultMaxIdleConnsPerHost), go1.27.3.
Get new Explainers by email
How real systems are built, in your inbox. No spam; unsubscribe any time.
More in APIs & Networking
1. Repo ProfileAgentgateway: a Rust gateway for HTTP, gRPC, MCP and LLM trafficAgentgateway is a Rust proxy that routes HTTP, gRPC, MCP, A2A and LLM traffic under one Gateway API control plane; minor releases still break config.22 October 2026
2. Repo ProfileCilium: eBPF networking, policy and observability for KubernetesCilium is an eBPF CNI for Kubernetes that replaces kube-proxy and enforces identity-based policy; the price is kernel 5.10+ and yearly upgrades.22 October 2026
Related
1. Repo Profileetcd: a Raft-replicated key-value store for coordination dataetcd is a Raft-replicated, linearizable key-value store for coordination-sized data; fast disks and a few-gigabyte ceiling are the price.22 October 2026
2. Repo ProfileFrankenPHP: a PHP application server with the interpreter inside CaddyFrankenPHP embeds the PHP interpreter in the Caddy web server: one Go binary, a worker mode, no PHP-FPM, but every extension must be thread-safe.22 October 2026
3. Repo Profilewitr: a CLI that traces a process, port or container to what started itwitr is a single-binary CLI and TUI that traces a process, port, container or file to what started it: a point-in-time diagnostic, not a monitor.22 October 2026


