Sitemap

Connection Pooling Is State Management: What Go 1.27 Changed in net/http

14 min readOct 17, 2026

--

How I examine HTTP/1 connection reuse across Go 1.26 and Go 1.27, and what it reveals about protocol state, bounded cleanup, and production performance

Press enter or click to view image in full size

A Go HTTP client can close every response body correctly, share a single http.Client across requests, and still abandon an otherwise healthy TCP connection. Until Go 1.27, even leaving a single trailing newline unread could be enough to prevent HTTP/1 connection reuse.

That behavior makes an important distinction visible: closing a resource is not the same thing as restoring its reusable state. The Go 1.27 improvement is interesting not merely because it reduces connection establishment overhead, but because it turns a protocol-state invariant into bounded, asynchronous work managed by the standard library.

The central principle is simple: a reusable connection is only reusable when the previous operation leaves the protocol in a state that the next operation can safely understand.

An innocent HTTP client with a hidden cost

Consider an application that needs only the status code of an HTTP response:

func getStatus(url string) (int, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}

The code follows the obligation to close the response body, but it never reads that body. In Go 1.26 and earlier, when using HTTP/1, that distinction could force the transport to discard the connection rather than return it to the idle pool. The Go team’s October 2026 explanation uses essentially this example and describes why it was so expensive.

The result is extra connection establishment, not necessarily an application-visible error. A service can therefore appear healthy while consuming more CPU, network handshakes, and latency than necessary. To understand why, we need to examine what the connection is actually carrying.

The connection pool is also a protocol-state machine

With HTTP/1, successive responses use the same ordered byte stream. net/http parses a response's status line and headers before returning Response to the caller. Its body is then read lazily through Response.Body. The boundaries of that body are established by the protocol's message framing, including Content-Length or chunked transfer encoding.

If a caller stops reading before the end of the response, bytes from response A still belong to A. They cannot be interpreted as the status line of response B. Returning that connection to the pool without restoring framing alignment would violate the protocol contract. This is the correctness constraint behind the older behavior, documented in the official Go blog.

The transport therefore needs more than an open TCP socket. It needs evidence that the previous HTTP response left the connection at a valid message boundary. The new behavior becomes clearer when we follow the application call that releases its response body.

What happens after Response.Body.Close()?

Starting in Go 1.27, net/http.Transport attempts to asynchronously drain an unread HTTP/1 response body after the application closes it. The current implementation limits that attempt to 256 KiB of remaining content or 50 ms. If draining reaches the end of the response within those bounds, the connection may become eligible for reuse. If it does not, abandoning the connection remains an acceptable outcome. The limits and asynchronous behavior come from the Go team's implementation write-up; the Go 1.27 release notes describe the feature at the API-behavior level.

Closing a response body and making its underlying connection reusable are different operations. In Go 1.26 and earlier, a response that was not read to completion typically prevented the HTTP/1.1 connection from returning to the idle pool. In Go 1.27, the transport may instead attempt to restore a reusable state with bounded asynchronous draining. The following figure compares both outcomes after the caller closes the body.

Press enter or click to view image in full size
Figure 1. HTTP/1.1 connection handling after Response.Body.Close() in Go 1.26 and Go 1.27. The new transport attempts bounded asynchronous draining, allowing reuse when the remaining response reaches EOF and the connection remains eligible.

Closing Response.Body completes the caller's lifecycle obligation, but the transport still decides whether the shared connection is safe for the next consumer. The diagram simplifies the transport's scheduling decisions. In particular, asynchronous cleanup does not guarantee that the very next request will wait for the recovered connection. A new request may use another connection while draining continues. Go also skips pointless draining when Connection: close already rules out reuse, as the Go blog explicitly notes.

The 256 KiB and 50 ms budgets are Go 1.27 implementation choices, not universal best practices or permanent API guarantees. Draining consumes bandwidth and time, so the transport does not attempt to preserve every connection at any cost. The practical API guidance remains to close the response body and let the transport manage the bounded cleanup.

Connection reuse requires a clean protocol handoff

The cleanup strategy illustrates a broader design requirement. A pooled HTTP/1.1 connection moves between successive consumers. Before Request B can use a connection previously serving Request A, the transport must know that Response A has ended and that the byte stream is positioned at a boundary the next operation can interpret correctly.

Reaching the logical end of Response A establishes that boundary. If the caller leaves response data unread, the transport must restore the required protocol state or discard the connection. Figure 2 illustrates that decision as a conceptual handoff, rather than a complete model of net/http.Transport internals.

Press enter or click to view image in full size
Figure 2. A reusable HTTP/1.1 connection requires a clean protocol handoff. Request B may reuse the connection only when Response A leaves a compatible protocol state, either directly or after successful cleanup. Otherwise, the connection is discarded.

A connection can remain open and healthy at the TCP layer without being safe to reuse at the HTTP layer. That distinction is why connection pooling is a state-management problem before it is a performance optimization. Eligibility comes from protocol correctness; reuse also depends on the connection remaining available in the transport’s pool.

The broader architectural principle is that a pooled resource is reusable only when its state is compatible with the next consumer. HTTP/1.1 makes this visible through response boundaries. HTTP/2 uses multiplexed frames to establish a different boundary, but it still has shared-resource constraints. With that handoff invariant established, we can examine the cost of recovering a connection and why Go 1.27 does not drain indefinitely.

Why connection establishment matters

A new HTTPS connection generally needs a TCP handshake followed by TLS negotiation before application traffic can fully use it. The exact round-trip cost depends on network conditions, TLS version, session resumption, and connection behavior, so a universal RTT count is misleading. The Go team’s breakdown contrasts TLS 1.2 and TLS 1.3 and explains why establishment remains measurable even with modern TLS.

Keeping an existing connection avoids repeating at least some of that setup. It can reduce latency, CPU utilization, and unnecessary churn, particularly across high-RTT networks. Yet preserving an existing connection is not free. If the response body contains hundreds of megabytes that the caller does not want, downloading all of it can cost much more than establishing another connection.

That tension is what makes Go 1.27’s approach more interesting than a blanket ‘always drain’ rule.

The single-byte problem with JSON decoding

A particularly surprising example comes from encoding/json.Decoder. A decoder can finish as soon as it has parsed one complete JSON value. Suppose the HTTP response contains:

{"status":"ok"}

After the closing brace, one newline byte may remain unread. In HTTP/1 on older Go releases, even that residue could block reuse. Reading a valid JSON document does not necessarily mean reading the entire HTTP message body. The distinction is directly illustrated in Better HTTP/1 connection reuse.

This is why the change helps more than obviously careless callers. It also benefits programs that successfully decode the application payload but leave protocol framing slightly unfinished. The behavior turns application-level completion and transport-level completion into two separate concepts.

Why manually draining everything was an imperfect workaround

Before Go 1.27, developers often used the following pattern when they wanted to preserve HTTP/1 connections:

io.Copy(io.Discard, resp.Body)
resp.Body.Close()

It can work for small finite bodies, but a slow server or unexpectedly large response can make the drain expensive or unbounded. A byte cap addresses volume; a time cap addresses slow delivery. Coordination then becomes necessary to prevent blocking the caller or leaking goroutines. The Go team’s walkthrough shows how a seemingly simple cleanup routine grows increasingly complex once these constraints are considered.

That is the real engineering improvement: the standard library now handles a common cleanup decision close to the protocol implementation, where it has the relevant state. For most Go 1.27 applications, the Go team’s guidance is to close the body without adding unconditional application-side draining.

The general net/http documentation still recommends reusing Client and Transport instances. Both are safe for concurrent use, and creating a fresh client/transport for each request undermines connection pooling independently of response-body handling. Do not use DisableKeepAlives as a reflexive fix for pool behavior; it intentionally disables persistent connection reuse and should be chosen only for a measured requirement. The Go 1.27 release notes also caution that increased reuse can expose rare configurations with excessive idle-connection retention.

HTTP/1 and HTTP/2 have different reuse boundaries

Connection reuse means different things depending on how a protocol organizes work over a shared connection. The distinction matters here because the Go 1.27 improvement addresses a specific constraint of HTTP/1, not a universal limitation of connection pooling.

Get Cássius Paim’s stories in your inbox

Join Medium for free to get updates from this writer.

In HTTP/1.1, a persistent TCP connection carries a sequence of HTTP responses. The client must determine where one response ends before it can correctly interpret the next. If an application closes Response.Body while bytes remain unread, the TCP connection may still be healthy, but the HTTP transport has not necessarily reached a safe handoff point. Reusing that connection without accounting for the remaining body could cause the next operation to encounter data from the previous response.

HTTP/2 changes this boundary. It multiplexes multiple logical streams over one TCP connection, with frames carrying stream identifiers. Responses can progress concurrently, and frames from different streams can be interleaved. An unread body on one stream therefore does not create the same sequential-response delimitation problem for the other streams. The Go team’s explanation of HTTP/1 connection reuse explicitly distinguishes these behaviors.

Figure 3 contrasts the sequential response boundaries of HTTP/1.1 with the independently identified streams of HTTP/2.

Press enter or click to view image in full size
Figure 3. HTTP/1.1 requires a clean response boundary before sequential reuse, while HTTP/2 multiplexes independently framed streams on one connection. An unread body on one HTTP/2 stream does not introduce the same sequential framing problem for other streams.

The difference is one of compatibility boundaries, not a declaration that multiplexing makes resource cleanup unnecessary. HTTP/1.1 must preserve message framing between sequential consumers; HTTP/2 can isolate individual streams while still sharing connection-wide resources.

Multiplexing does not make shared resources free or eliminate lifecycle management. HTTP/2 still has per-stream and connection-level flow control, limits on concurrent streams, cancellation, buffering, and memory pressure. A consumer that stops reading can leave a stream consuming resources or flow-control capacity until it is handled appropriately. The difference is that the transport can manage or cancel the affected stream without necessarily giving up the underlying TCP connection. The HTTP/2 specification, RFC 9113, defines stream states, flow control, and cancellation behavior.

This protocol distinction matters when designing the benchmark. An HTTPS client may negotiate HTTP/2 automatically, which would shift the experiment toward a different reuse model and mask the HTTP/1 behavior under investigation. To compare Go 1.26 and Go 1.27 meaningfully, the laboratory should explicitly use HTTP/1, keep client and server conditions equivalent, and record actual reuse with net/http/httptrace. The Go team's published experiment uses GODEBUG=http2client=0 to disable HTTP/2 on the client side; a controlled local HTTP/1 server provides another way to keep the protocol fixed.

A reproducible benchmark, not a universal speed claim

The benchmark uses a local HTTP/1.1 httptest.Server and a reusable http.Client with a fresh Transport for each scenario. It deliberately avoids network and TLS variability to isolate HTTP/1 connection handling. The client uses httptrace.ClientTrace and GotConnInfo.Reused to distinguish new acquisitions from reused connections. Server-side connection acceptance provides a second signal. The exact same eight scenarios were run with Go 1.26.8 and Go 1.27.2 on my local environment.

Each scenario makes 100 sequential requests, uses a configured 10 ms pause after each request, and recorded zero errors. The experiment includes bodies left unread, partially read bodies, a body near the cleanup byte budget, a large body, JSON decoding, slow delivery, Connection: close, and a control that reads to EOF.

Connection reuse results

The clearest evidence concerns connection state rather than absolute speed. The table reports request acquisitions with a previously used connection, and how many TCP connections the server accepted for each 100-request scenario.

Scenario              | Reuse 1.26 | Reuse 1.27 | TCP 1.26 | TCP 1.27
----------------------+------------+------------+----------+---------
Ignored 1 KiB | 0/100 | 99/100 | 100 | 1
Partially read 64 KiB | 0/100 | 99/100 | 100 | 1
Near 256 KiB | 0/100 | 99/100 | 100 | 1
Ignored 1 MiB | 0/100 | 0/100 | 100 | 100
JSON plus newline | 99/100 | 99/100 | 1 | 1
Slow body | 0/100 | 0/100 | 100 | 100
Connection: close | 0/100 | 0/100 | 100 | 100
Read to EOF | 99/100 | 99/100 | 1 | 1

The Go 1.27.2 result is particularly clear for the 1 KiB ignored body, 64 KiB partially read body, and near-256 KiB partially read body. All three move from zero reused acquisitions to 99 out of 100, while server-accepted TCP connections fall from 100 to one. This is strong local evidence of bounded draining restoring HTTP/1 connection eligibility. The near-256 KiB case does not precisely prove behavior at the 256 KiB cutoff: its 32-byte read leaves slightly less than 256 KiB, and internal buffering can affect the transport’s remaining work.

Conversely, both versions create 100 connections in the 1 MiB case, the slow-body case, and the Connection: close case. Those results are consistent with a byte budget, time budget, and explicit connection-close semantics respectively, although the CSV alone does not expose the transport's internal reason for discarding each connection.

An important counterexample is JSON plus newline: even Go 1.26.8 reused 99 out of 100 connections. The decoder’s measured application-facing read count is 1,600 bytes across 100 requests, or 16 bytes per response, consistent with its reader obtaining the entire small payload. This controlled case did not reproduce a leftover-newline reuse failure. It should not be used as evidence that the Go 1.27 improvement fixed the JSON case in this particular run. The full-EOF control also reused 99 out of 100 connections in both versions, as expected.

Request latency results

These are the measured per-request total durations, in milliseconds, including the benchmark’s request processing phases and Body.Close(). Lower is better. Percentages are calculated from the reported p50 values, not from pooled raw samples.

Scenario              | 1.26 p50/p95 ms | 1.27 p50/p95 ms | p50 delta
----------------------+-----------------+-----------------+----------
Ignored 1 KiB | 0.586 / 0.993 | 0.382 / 0.915 | -34.8%
Partially read 64 KiB | 0.738 / 1.106 | 0.400 / 0.711 | -45.8%
Near 256 KiB | 0.826 / 1.891 | 0.367 / 1.193 | -55.6%
Ignored 1 MiB | 0.736 / 1.547 | 0.886 / 1.853 | +20.4%
JSON plus newline | 0.412 / 0.849 | 0.353 / 0.728 | -14.3%
Slow body | 0.825 / 1.160 | 0.753 / 1.231 | -8.7%
Connection: close | 0.765 / 1.354 | 0.681 / 1.112 | -11.0%
Read to EOF | 0.615 / 0.847 | 0.626 / 0.936 | +1.8%

On the three scenarios where connection reuse changed, p50 request duration decreased by approximately 34.8%, 45.8%, and 55.6%, respectively. These are local observations, not an across-the-board Go 1.27 speed claim. In the 1 MiB case, median duration actually increased by approximately 20.4% in this run. Other scenarios show mixed changes despite identical reuse behavior, which cautions against attributing every latency difference to connection pooling.

The reported throughput also changes much less than the reuse ratio: for the ignored 1 KiB case it increases from 84.31 to 88.63 requests/s. Because each scenario includes a 10 ms pause per request, wall-clock throughput is strongly influenced by artificial pacing and cannot be interpreted as the transport’s maximum sustainable throughput. Likewise, reused_close_to_gotconn_p50_ms near 11 ms chiefly reflects that configured pause plus scheduling and request acquisition; it does not directly measure the internal asynchronous drain duration.

Reproducibility and limits

A third file, results.csv, contains another run of Go 1.26.8 with the same eight scenarios and 100 requests per scenario. Its reuse decisions match the primary Go 1.26.8 run exactly, but its latency results differ significantly. For example, ignored-1-KiB p50 is 1.669 ms in the additional run versus 0.586 ms in the paired comparison file. That variation reinforces the need for repeated, alternated runs before making strong statistical claims about latency.

The CSVs do not specify CPU, operating system, load, or detailed server timing, so no machine-specific conclusions are made here. This experiment validates the mechanism, especially connection eligibility and reuse; it does not quantify production HTTPS savings, TLS handshake costs, cross-region RTT, or effects under concurrency. The published Go team benchmark is a separate experiment, not a direct replication of these local numbers.

The complete project is prepared for publication in go-http-reuse-lab repository. It contains the local server, controlled scenarios, instrumentation, CSV output, and comparison scripts. Replace the repository placeholder after publishing the code.

What the metrics can and cannot establish

A reuse ratio gives direct evidence about connection acquisition, but it is not a complete performance model. The correct cost comparison includes drained bytes, wasted network work, dial latency, TLS negotiations for real HTTPS deployments, CPU, idle pool occupancy, and tail latency. Local HTTP removes network variance but also hides the cross-region RTT and TLS effects that make establishment expensive in production.

The experiment should therefore separate mechanism from impact. The local tests establish whether the transport behaves differently under controlled response bodies. A second, explicitly labeled production-like benchmark can estimate whether that difference materially helps the application’s workload. These are distinct empirical questions and should not be collapsed into a single headline number.

The transport also makes an economic trade-off: a larger drain allowance may recover more connections while burning bandwidth and holding resources longer. A smaller allowance wastes fewer bytes but may discard connections that would have been cheap to salvage. Bounded asynchronous draining is a deliberate compromise, not evidence that 256 KiB or 50 ms is universally optimal.

Shared resources need explicit compatibility rules

An HTTP/1 connection is only one example of a broader systems principle. A database session returned to a pool may carry transaction state or session-level settings. A reusable buffer may contain stale contents. A cached protocol response may depend on capabilities that were negotiated for a different consumer. The handoff is safe only when the next consumer receives state it can interpret correctly.

A useful, independent parallel appeared in Cloudflare’s October 2026 account of a Hyperdrive MySQL caching bug. In that case, reuse of cached protocol representations had to respect negotiated capabilities. The specific failure mode is different from Go’s HTTP/1 body-draining problem, and neither implementation caused the other. They illuminate the same architectural requirement: shared resources need explicit compatibility rules.

The lesson extends to pool design and cache design alike. Before optimizing reuse, define the invariant that permits the next consumer to proceed safely. Then decide whether it is cheaper to restore the invariant, validate it, or discard the resource.

Conclusion

Go 1.27’s HTTP/1 change does not make closing a body synonymous with guaranteed connection reuse. It makes a common recovery step asynchronous, bounded, and internal to the transport. That is a more useful abstraction because it keeps the caller’s API simple while preserving the protocol correctness boundary.

The practical guidance is straightforward. Close every response body because that remains the API contract. Reuse clients and transports because pooling remains central to efficiency. Let Go 1.27 attempt bounded cleanup instead of adding universal manual drains. Finally, measure the actual workload before extrapolating from a published speedup.

The deeper engineering principle is more durable than this particular release: performance improvements built around reuse are correct only when the system can restore or verify the state needed by the next consumer.

💬 Join the Discussion

Have you encountered an HTTP client that looked correct but quietly created a fresh connection per request? Which metrics helped you distinguish connection churn from application latency? And where else have you had to make a shared resource safe before returning it to a pool?

Originally published on Medium by Cassius Paim, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin

Tags

#Golang · #HTTP · #Performance Engineering · #Connection Pooling · #Software Architecture

--

--

Cássius Paim
Cássius Paim

Written by Cássius Paim

Software Engineer | Software Architect | Golang | Kotling | Java