Guide · Backend
HTTP Connection Reuse Explained: Keep-Alive, Pools and Unread Response Bodies
18 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- HTTP connection reuse means sending many requests over one open connection instead of opening a new one each time. It skips the handshakes, the setup messages that TCP (the protocol that carries the bytes) and TLS (the encryption behind HTTPS) need first.
- HTTP/1.1 keeps a connection open after each response by default, which is called keep-alive. Your HTTP client parks idle open connections in a pool, grouped by server, for the next request.
- On an HTTP/1.1 connection, responses come one after another. The client must reach the end of a response body, the data after the headers, before the connection is free again.
- Go 1.27, released on 19 August 2026, now reads the rest of an unread HTTP/1 body for you when you close it, up to 50 ms or 256 KB. In Python, one requests.Session reuses connections.
- Beginners can stop after the Python section. Engineers get the drain rules we read in the Go 1.27.2 source and tested: bodies declared over 256 KB are never drained, and the next call does not wait.
What goes wrong when a service opens a new connection for every HTTP request?
Every request waits for fresh handshakes before any useful data moves, so a loop of small calls spends most of its time setting up connections. HTTP connection reuse removes that wait by keeping one connection open for many requests.
Picture a common setup. A parcel-tracking team calls a courier partner's API every evening. A Go job asks whether 100 new tracking numbers exist, checks only the status code and never reads the JSON body.
A Python reporting job fetches the day's delivery events from the same partner by calling requests.get in a loop. The partner's API speaks HTTP/1.1. The setup is made up, but the problem is real.
Both jobs are slow. A packet capture, a recording of every network packet a machine sends, shows a new connection with full handshakes for almost every request. This guide fixes both jobs and shows you how to check the fix.
Which words do you need before learning HTTP connection reuse?
You need six words.
- TCP connection: a two-way pipe between two machines that delivers bytes in order.
- Round trip time (RTT): the time for a message to reach the other machine and for a reply to come back. The team's RTT to the partner is 20 ms (made up).
- Handshake: the opening messages that set up a connection. TCP needs one round trip, and TLS, the layer that encrypts HTTPS, needs one or two more.
- Response body: the content after the status code and headers, such as the partner's JSON about a parcel.
- Keep-alive: leaving a connection open after a response so the next request can use it. HTTP/1.1 does this by default.
- Connection pool: a client-side shelf of idle, open connections, grouped by server. A request borrows one and returns it when it is done.
What is HTTP connection reuse and how does it work?
HTTP connection reuse is sending the second, third and hundredth request on a connection that is already open. When a response is finished, the connection waits in the client's pool for the next request to the same server.
Think of the autorickshaw stand outside Pune station. The rickshaw is the connection. Calling one from across town is the handshake wait, and the stand is the pool, where a rickshaw is ready to leave at once.
Where the picture breaks: a rickshaw at the stand takes any passenger, but a pooled connection only serves the server it was opened to. A call to a different host cannot borrow it.
RFC 9112, the HTTP/1.1 standard, sets the rules. HTTP/1.1 connections stay open by default, and a Connection: close header ends one after the current response. Older HTTP/1.0 kept a connection open only when a keep-alive option was sent.
What a new connection costs
The Go team's post counts one round trip for the TCP handshake, then two for TLS 1.2 or about one for TLS 1.3. Only then does the request go out, which takes one more round trip.
Here is the simple model for the Go job, using its 20 ms RTT with TLS 1.2 and ignoring server time. A call on a new connection waits about 4 round trips, 80 ms. A call on a reused connection waits 1 round trip, 20 ms.
For 100 calls in a row, that is about 8 seconds against about 2 seconds. The gap is pure waiting, which is why a packet capture full of handshakes is a red flag.
One call on a new connection, then on a reused one
Drag to exploreNotice that only the first request pays for the three handshake round trips. The second skips straight to its GET, but only if the first body was read to the end.
Diagram connections:
- TCP handshake to TLS 1.2 handshake (Round trip 1)
- TLS 1.2 handshake to GET tracking number 1 (Round trips 2 and 3)
- GET tracking number 1 to 200 OK plus JSON body
- 200 OK plus JSON body to Back to the idle pool (Body read to the end)
- Back to the idle pool to GET tracking number 2 (Same connection)
- TCP handshake: Go job → partner API, 1 round trip
- TLS 1.2 handshake: Go job → partner API, 2 round trips
- GET tracking number 1: Go job → partner API
- 200 OK plus JSON body: Partner API → Go job
- Back to the idle pool: Go job → its own pool, body fully read
- GET tracking number 2: Go job → partner API, no handshake
Why must you read the whole HTTP/1 response body to reuse a connection?
Because HTTP/1 responses sit back to back on one connection, in the order the requests were sent. The client can only find the start of the next response after it reaches the end of the current one.
HTTP/1 here means HTTP/1.0 and HTTP/1.1. RFC 9112 gives three ways to mark where a body ends. A Content-Length header states the size in bytes, and chunked transfer encoding sends pieces ending with a zero-size piece.
The third way is to close the connection when the body ends. That connection can never be reused, so servers that want keep-alive use one of the first two.
When code closes a body before its end, the client has two choices. It can read and throw away the leftover bytes, called draining, then reuse the connection. Or it can close the connection and open a new one later.
Through Go 1.26, net/http, Go's built-in HTTP library, took the second path. We checked on Go 1.26.9: 100 calls that closed a 2 KB body unread opened 100 connections. That is what slowed the Go job.
The Go post notes a sneakier case. A JSON decoder, the code that turns JSON text into data, can stop after the closing brace and leave a trailing newline unread. A related Go issue from 2017, number 20528, traced lost reuse to the body's end marker arriving after the decoder stopped reading.
What changed in Go 1.27 for unread HTTP response bodies?
Since Go 1.27, closing an unread HTTP/1 response body makes Go drain the rest in the background, for up to 50 ms or 256 KB. If the drain reaches the end, the connection goes back to the pool.
The work happens in the Transport, the part of net/http that opens and pools connections. If a response carries Connection: close, Go skips draining, since that connection cannot be reused anyway.
What Go 1.27 does when code closes an HTTP/1 body
Drag to exploreNotice that the size check and the drain step are new in Go 1.27. Before it, closing an unread body always meant a closed connection.
Diagram connections:
- Code closes the body to Connection: close sent?
- Connection: close sent? to Connection closed (Yes: never reused)
- Connection: close sent? to Body read to the end? (No)
- Body read to the end? to Idle pool (Yes)
- Body read to the end? to Declared over 256 KB? (No)
- Declared over 256 KB? to Connection closed (Yes: no drain)
- Declared over 256 KB? to Drain in the background (No, or unknown)
- Drain in the background to End within 50 ms, 256 KB?
- End within 50 ms, 256 KB? to Idle pool (Yes)
- End within 50 ms, 256 KB? to Connection closed (No)
- Code closes the body: Status checked, body maybe unread
- Connection: close sent?: Server said this is the last response
- Body read to the end?: Up to Content-Length or the last chunk
- Declared over 256 KB?: Content-Length header; chunked counts as unknown
- Drain in the background: Reads leftovers; Close has already returned
- End within 50 ms, 256 KB?: Small, quick bodies finish in time
- Idle pool: Ready for the next request, no handshake
- Connection closed: Next request pays for new handshakes
Before this, careful Go code drained bodies by hand before closing them. The post walks through doing that safely, with a byte cap and a 50 ms time cap, and calls it tricky. Its new advice is simply to close bodies once you no longer need them.
The post measures 100 GET requests in a row to index.golang.org that check only status codes. HTTP/2, the newer version that runs many requests side by side on one connection, was switched off. The run took about 710 ms on Go 1.26 and about 150 ms on Go 1.27, on one author's machine.
The team moves the Go job to Go 1.27.2 without changing a line, and the packet capture now shows almost every call on the same connection. By the model above, the batch of 100 checks drops from about 8 seconds to about 2.
The change applies only to HTTP/1 connections
Go's docs say DefaultTransport, the shared Transport that http.Get uses, turns on HTTP/2 for HTTPS. So HTTPS calls through http.Get to a server that offers HTTP/2 never had this problem.
It still matters for plain HTTP URLs without TLS and servers that speak only HTTP/1.1. It also matters for a Transport given its own TLS settings (TLSClientConfig) or dial function (DialContext). Go then turns HTTP/2 off unless you set ForceAttemptHTTP2 to true.
How do you reuse HTTP connections in Python with the requests library?
Sharing one requests session for the whole job
In Python, create one requests.Session, send every call through it, and let each body be read. A bare requests.get call builds a fresh session and closes it when the call returns, so nothing is reused.
A Session pools connections through urllib3, the library requests uses underneath. The requests docs say a connection goes back to the pool only once all body data has been read. Here is the fix for the reporting job.
import logging
import requests
logging.basicConfig(level=logging.DEBUG) # urllib3 logs every new connection
HOST = "partner.example" # made-up partner API: use your own host
def fetch_events(tracking_ids):
events = []
with requests.Session() as session: # one pool for the whole job
for tid in tracking_ids:
url = f"https://{HOST}/v1/events/{tid}"
resp = session.get(url, timeout=5) # body is read in full
if resp.status_code == 200:
events.append(resp.json())
return events
print(len(fetch_events(["TK1001", "TK1002", "TK1003"])), "events")By default requests reads the whole body before session.get returns, even for a 404 (not found) response, so the connection is free for the next call. The logging line is your check.
Run it against your own host and expect one log line containing Starting new HTTPS connection (1), then three GET lines and 3 events. The bare requests.get loop prints a Starting new line for every call.
We counted this on requests 2.34.2 with urllib3 2.8.0 and Python 3.13. Twenty stream=True calls closed unread opened 20 connections. The same calls read to the end with iter_content opened 1.
Seeing the body rule with Python's built-in http.client module
In Python's lower-level http.client module, one HTTPSConnection object is one connection. It shows the body rule directly: read each response before you ask for the next.
import http.client
conn = http.client.HTTPSConnection("partner.example", timeout=5) # made-up host
for tid in ["TK1001", "TK1002", "TK1003"]:
conn.request("GET", f"/v1/labels/{tid}")
resp = conn.getresponse()
body = resp.read() # skip this read and the next getresponse() fails
print(tid, resp.status, len(body), "bytes")
conn.close()Replace resp.read() with b"" and the first line still prints, then the second getresponse() call raises http.client.ResponseNotReady. The first body is still sitting on the connection, ahead of the next response.
Beginners can stop here. The rest is for engineers: the exact Go 1.27 drain rules with test results, pool sizes in Go and Python, and an upgrade trap.
What exactly does Go 1.27 drain, and when is the connection still lost?
Go 1.27 drains only small, quick leftovers, and the next request does not wait for the drain. Everything below was read in the Go 1.27.2 source or run on a local test server.
What the Go 1.27.2 source adds to the release notes
The release notes only promise to drain up to a conservative limit. The Go blog post gives the two caps, 50 ms and 256 KB, and src/net/http/transport.go at go1.27.2 sets them as constants (256 KB is 262,144 bytes). The declared-size check and the race below appear in none of the release notes, the post or the package docs. They are undocumented as of 19 October 2026, so pin your version.
- Go only tries to drain when the declared Content-Length is 256 KB or less. A chunked body has no declared length, so Go tries it and gives up past 256 KB.
- Close returns at once. The connection's reader goroutine, a lightweight Go thread, handles the drain in the background, so your code does not wait for it.
- The next request does not wait for the drain either. If it needs a connection while the drain is still running, Go can open a new one.
We ran each rule on Go 1.26.9 and Go 1.27.2 against a local HTTP/1.1 test server. The table counts new TCP connections for 100 calls that check the status and close the body unread. Before you read it, guess which rows Go 1.27.2 still loses.
| Response body | Go 1.26.9 | Go 1.27.2 |
|---|---|---|
| 2 KB with Content-Length | 100 | 2 |
| 200 KB with Content-Length | 99 | 0 |
| 300 KB with Content-Length (over the limit) | 100 | 98 |
| 100 KB chunked | 100 | 4 |
| 1 MB chunked | 100 | 98 |
On a slower link the 50 ms cap can bite first. When our test server sent the same 200 KB body at about 10 Mbps (megabits per second), taking about 160 ms, Go 1.27.2 opened 20 new connections for 20 calls.
The last part of a body can also arrive late. In one test the rest of the body came 10 ms after its first part. Go 1.27.2 reused the connection when the next call started 60 ms later, but not when it started at once. With a 100 ms tail and the same pause, 18 of 20 calls still opened a new connection, which matches the 50 ms cap.
The JSON case from earlier depends on timing. On Go 1.26.9, decoded JSON bodies kept their connection when the end-of-body marker arrived with the JSON. When the server sent that marker 5 ms later, 49 of 50 calls opened a new connection.
Go 1.27.2 drains that late marker. With 20 ms between calls, it opened 0 new connections in the same 50-call test.
How to check reuse in your own Go program
Go's httptrace package can report, for each request, whether it got a reused connection. This program runs a local test server, makes 100 calls that close the body unread and counts the reused connections.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"runtime"
"strings"
)
func main() {
// A local HTTP/1.1 test server that returns a 2 KB body.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(strings.Repeat("x", 2048)))
}))
defer srv.Close()
reused := 0
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
if info.Reused {
reused++
}
},
}
for i := 0; i < 100; i++ {
req, _ := http.NewRequest("GET", srv.URL, nil)
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
resp.Body.Close() // status checked, body never read
}
fmt.Println(runtime.Version(), "reused", reused, "of 100")
}Save it as main.go and run go run main.go. On Go 1.27.2 we saw 97 to 99 reused of 100 across 20 runs; the misses are the race in the third rule above. Go 1.26.9 printed 0 of 100.
How does a connection pool decide how many connections to keep?
A pool keeps a limited number of idle connections for each server and closes connections left unused too long. Those two limits decide whether a burst of calls finds connections waiting.
Go's DefaultTransport, which http.Get and http.DefaultClient use, keeps at most 100 idle connections across all hosts and 2 per host. It closes a connection that stays idle for 90 seconds.
A Transport you build yourself with &http.Transport{} starts with MaxIdleConns and IdleConnTimeout at zero. The docs say zero means no limit: no cap across all hosts and no idle timeout. The default of 2 per host still applies, and cloning DefaultTransport keeps its limits.
The team's Go webhook sender, which pushes parcel updates to the partner, fires 30 calls at once. In our test of 5 such bursts, each burst after the first opened 28 new connections, because only 2 could wait in the pool.
Raising the per-host limit, MaxIdleConnsPerHost, to 32 dropped that to 0 new connections per burst. MaxIdleConnsPerHost only limits idle connections; MaxConnsPerHost caps all connections to a host and defaults to no limit.
Sizing the pool in Python requests
A requests Session keeps up to 10 connections per host by default, the pool_maxsize setting of its HTTPAdapter. If more threads call the same host at once, extra connections are thrown away after use.
You can spot this in the logs: urllib3 warns Connection pool is full, discarding connection. In our test, 16 threads making 5 bursts of calls opened about 40 connections. With pool_maxsize set to 16, they opened 16.
import requests
from requests.adapters import HTTPAdapter
session = requests.Session()
# One pool slot per thread that calls the same host at the same time.
session.mount("https://", HTTPAdapter(pool_maxsize=16))| Pattern | Reuses the connection? | Why |
|---|---|---|
| Python requests.get in a loop | No | Each call builds and closes its own session |
| One requests.Session, default settings | Yes | The body is read for you, even for a 404 |
| requests stream=True, closed unread | No | Close shuts the unread connection |
| Go 1.26, HTTP/1 body closed unread | No | The connection is thrown away |
| Go 1.27, small body closed unread | Usually | Drained in the background if it ends within 50 ms |
| Go 1.27, body declared over 256 KB, closed unread | No | Too big to try draining |
| New Go Transport for every request | No | Each Transport starts with its own empty pool |
Can upgrading to Go 1.27 make an HTTP client slower?
Yes, in rare cases, say the Go 1.27 release notes. Programs that gain nothing from reuse could get slower if they let far too many idle connections linger.
The notes name two usual causes: setting MaxIdleConns to 0, or using different Clients for different requests, which bypasses that limit. They offer a switch, DisableKeepAlives set to true, but say such a slowdown usually points to a misconfigured Transport or Client.
The team finds one: an SMS helper builds a new Go client with its own Transport on every call and closes each body unread. In our test of 100 such calls, Go 1.26.9 closed every connection. On Go 1.27.2, all 100 stayed open on the server. On either version, a per-call Transport that reads its bodies leaves its connections open too, so share one client.
How do you explain HTTP connection reuse in a system design interview?
Start with the cost of a new connection, then show how the client avoids paying it twice.
Price the handshake
A new HTTPS connection costs one round trip for TCP, then two for TLS 1.2 or one for TLS 1.3, before the request goes out.
Name keep-alive and the pool
HTTP/1.1 keeps connections open by default. The client parks idle ones in a pool, grouped by host.
Explain the body rule
On HTTP/1.1, the next response can only be read after the current body ends. Unread bodies can cost you the connection.
Set pool limits
Match idle connections per host to peak parallel calls, and close idle ones after a timeout.
Mention the 2026 change
Go 1.27 drains unread HTTP/1 bodies, up to 50 ms or 256 KB, when they are closed. HTTP/2 avoids the problem by interleaving many requests on one connection.
Say how you would measure it
Track new connections per minute for each service you call. In Go, httptrace reports whether each request reused a connection; in Python, urllib3's DEBUG log shows each new one.
Questions people ask
- Does HTTP/2 have the same unread body problem?
- No. The Go post explains that HTTP/2 multiplexes, which means it runs many requests side by side on one connection as separate streams. One unread body does not block the next response. That is why Go's draining change applies only to HTTP/1.
- Do I still need to drain response bodies by hand in Go 1.27?
- Usually not. The Go team now recommends simply closing a body once you no longer need it. Go drains up to 50 ms or 256 KB for you. Bodies declared larger than 256 KB are not drained. The Go post notes that a new connection can be faster than downloading and discarding the rest, so read a large body to the end only when that is cheaper.
- Is requests.get bad to use in Python?
- No, it is fine for a single call. Each call builds and closes its own session, so nothing is reused between calls. For loops or many calls to one host, send every call through one requests.Session.
- How many idle connections should a pool keep per host?
- Roughly as many as your peak number of calls in flight to that host at once. Go's default of 2 per host suits calls made one after another. Bursty traffic needs a higher limit, or each burst opens fresh connections.
- Does connection reuse help if my service calls many different hosts?
- Less so, because a pooled connection only serves the host it was opened to. Go's Transport docs warn that caching connections may leave many open connections when you access many hosts.
- Does a Go Transport I create myself use HTTP/2?
- Often yes, despite one doc line. As of Go 1.27.2, the package docs say Transport does not enable HTTP/2 by default. Yet in our test a plain &http.Transport{} used HTTP/2 against an HTTPS server that offered it. Setting TLSClientConfig or DialContext switched it to HTTP/1.1, unless ForceAttemptHTTP2 was true.
- Should I set DisableKeepAlives to true after upgrading to Go 1.27?
- Only as a last resort. The Go 1.27 release notes offer it for programs that never benefit from reuse. They add that such a slowdown usually means a misconfigured Transport or Client, so share one client instead.
Sources and further reading
- Better HTTP/1 connection reuse (The Go Blog, 14 October 2026)
- Go 1.27 Release Notes: net/http
- Go release history: go1.27.0 to go1.27.2 dates
- Go net/http package documentation (DefaultMaxIdleConnsPerHost, Client, HTTP/2)
- Go 1.27.2 source: src/net/http/transport.go (drain limits, Transport defaults)
- Go issue 20528: json.NewDecoder(resp.Body) would not close the response body
- Python http.client documentation (read the whole response before a new request)
- Requests API reference: HTTPAdapter defaults (pool_maxsize 10)
- RFC 9112: HTTP/1.1, sections 6.3 and 9.3 (June 2022)
- Requests documentation: Advanced Usage (Session objects, Keep-Alive, Body Content Workflow)