Overview

When using HTTP, whether in a browser or an SDK’s HTTP client, we often have the need to reuse HTTP connections. This means making multiple HTTP requests and responses over the same connection, a concept commonly known as HTTP persistent connection (Keep-Alive).

In this article, we will explore persistent connections from both the protocol support perspective and its practical implementation in Go.

Protocol Support

The RFC for HTTP/1.1 dedicates an entire section to discussing persistent connections. Right at the beginning, it outlines the benefits of using persistent connections:

  • Reduces resource consumption across all involved devices (such as CPU and memory usage on clients, servers, and routers).
  • Enables multiple requests and responses to be pipelined over the same connection, improving connection efficiency.
  • Decreases the number of TCP connection establishments and closures, reducing network congestion by lowering the overall number of transmitted packets.
  • Lowers response latency by eliminating the overhead of establishing new connections.
  • Improves error handling since persistent connections allow for error reporting, whereas short connections can only indicate failures by terminating the connection.

According to the HTTP/1.1 RFC, persistent connections are the default behavior in HTTP/1.1. A connection is only considered short-lived if either the client or the server explicitly sends a Connection: Close header.

For persistent connections, if the connection is unexpectedly closed during transmission, the client should retry idempotent requests but must not retry non-idempotent requests. The HTTP/1.1 RFC specifies that GET, HEAD, PUT, and DELETE are idempotent request methods.

Go Implementation

Next, let’s explore how Go 1.22 handles the HTTP/1.1 protocol, with separate discussions for the Client and Server implementations.

Client

In Go, connection management is handled through the Transport layer, which implements the RoundTripper interface:

  1. [root@liqiang.io]# cat client.go
  2. type RoundTripper interface {
  3. RoundTrip(*Request) (*Response, error)
  4. }

Since RoundTripper is an interface, we need to provide an implementation. However, by default, the HTTP client uses Go’s built-in default RoundTripper:

  1. [root@liqiang.io]# cat transport.go
  2. var DefaultTransport RoundTripper = &Transport{
  3. Proxy: ProxyFromEnvironment,
  4. DialContext: defaultTransportDialContext(&net.Dialer{
  5. Timeout: 30 * time.Second,
  6. KeepAlive: 30 * time.Second,
  7. }),
  8. ForceAttemptHTTP2: true,
  9. MaxIdleConns: 100,
  10. IdleConnTimeout: 90 * time.Second,
  11. TLSHandshakeTimeout: 10 * time.Second,
  12. ExpectContinueTimeout: 1 * time.Second,
  13. }
  14. func (t *Transport) roundTrip(req *Request) (*Response, error) {
  15. ... ...
  16. for {
  17. select {
  18. case <-ctx.Done():
  19. req.closeBody()
  20. return nil, ctx.Err()
  21. default:
  22. }
  23. treq := &transportRequest{Request: req, trace: trace, cancelKey: cancelKey}
  24. cm, err := t.connectMethodForRequest(treq)
  25. ... ...
  26. pconn, err := t.getConn(treq, cm)
  27. ... ...
  28. resp, err = pconn.roundTrip(treq)
  29. ... ...
  30. } else if !pconn.shouldRetryRequest(req, err) {
  31. ... ...
  32. return nil, err
  33. }
  34. ... ...
  35. // Rewind the body if we're able to.
  36. req, err = rewindBody(req)
  37. if err != nil {
  38. return nil, err
  39. }
  40. }
  41. }

From this simple implementation, we can see that Go’s HTTP Client uses persistent connections by default. It allows up to 100 concurrent persistent connections and has a default idle timeout of 90 seconds. Additionally, in certain cases, the client will automatically retry requests. Below are some scenarios where retries may occur:

  • When too many streams are opened on a single connection in an HTTP/2 scenario.
  • When the request does not contain a body or when the body can be re-acquired (in this case, Go does not explicitly distinguish between idempotent request methods).
  • When a non-EOF error occurs before reading the first byte of the response.
  • When the server closes an idle connection.

From the above code snippet, we can see that Go’s HTTP client implements these features. Now, let’s dive deeper into the underlying code logic to explore additional details:

  1. [root@liqiang.io]# cat transport.go
  2. func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
  3. ... ...
  4. w := &wantConn{
  5. cm: cm,
  6. key: cm.key(),
  7. ctx: ctx,
  8. ready: make(chan struct{}, 1),
  9. beforeDial: testHookPrePendingDial,
  10. afterDial: testHookPostPendingDial,
  11. }
  12. if delivered := t.queueForIdleConn(w); delivered {
  13. pc := w.pc
  14. ... ...
  15. t.setReqCanceler(treq.cancelKey, func(error) {})
  16. return pc, nil
  17. }
  18. ... ...
  19. }
  20. func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
  21. if t.DisableKeepAlives {
  22. return false
  23. }
  24. t.idleMu.Lock()
  25. defer t.idleMu.Unlock()
  26. ... ...
  27. var oldTime time.Time
  28. if t.IdleConnTimeout > 0 {
  29. oldTime = time.Now().Add(-t.IdleConnTimeout)
  30. }
  31. // Look for most recently-used idle connection.
  32. if list, ok := t.idleConn[w.key]; ok {
  33. stop := false
  34. delivered := false
  35. for len(list) > 0 && !stop {
  36. pconn := list[len(list)-1]
  37. tooOld := !oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime)
  38. if tooOld {
  39. go pconn.closeConnIfStillIdle()
  40. }
  41. if pconn.isBroken() || tooOld {
  42. list = list[:len(list)-1]
  43. continue
  44. }
  45. delivered = w.tryDeliver(pconn, nil)
  46. if delivered {
  47. if pconn.alt != nil {
  48. } else {
  49. t.idleLRU.remove(pconn)
  50. list = list[:len(list)-1]
  51. }
  52. }
  53. stop = true
  54. }
  55. if len(list) > 0 {
  56. t.idleConn[w.key] = list
  57. } else {
  58. delete(t.idleConn, w.key)
  59. }
  60. if stop {
  61. return delivered
  62. }
  63. }
  64. if t.idleConnWait == nil {
  65. t.idleConnWait = make(map[connectMethodKey]wantConnQueue)
  66. }
  67. q := t.idleConnWait[w.key]
  68. q.cleanFront()
  69. q.pushBack(w)
  70. t.idleConnWait[w.key] = q
  71. return false
  72. }

From the code, we can see that Go’s implementation does not follow the RFC’s specification, which allows multiple ongoing requests over a single connection. Instead, in Go, each connection handles only one active request at a time. To manage multiple connections efficiently, Go maintains a connection pool, where connections are indexed using a key composed of the schema, method, and address.

Another interesting detail in the code is the use of an LRU (Least Recently Used) data structure. This structure is employed when the number of connections exceeds the limit—Go will close the longest idle connection to free up resources.

Returning Connections to the Pool

From the request-sending logic discussed earlier, we can see that connections are obtained via the function func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool). Based on its implementation, we know that connections are stored in the idleConn connection pool. Since connections are borrowed, they must also be returned at some point. So, when exactly does this happen?

First, near the code responsible for obtaining connections, we can see the function func (t *Transport) tryPutIdleConn(pconn *persistConn) error. Based on its implementation, this function appears to be responsible for returning connections. To confirm this, we need to trace where this function is called. Excluding some exceptional handling cases, there are two main scenarios to focus on:

  • When an error occurs while processing a request. However, this does not refer to network errors (in which case the connection should be closed), but rather errors related to internal state handling.
  • When a request is completed and the response body has been fully read. The condition for being “fully read” is that the read operation returns an EOF.

I am particularly interested in the second scenario, so let’s walk through the code to analyze it further.

  1. [root@liqiang.io]# cat transport.go
  2. select {
  3. case bodyEOF := <-waitForBodyRead:
  4. replaced := pc.t.replaceReqCanceler(rc.cancelKey, nil) // before pc might return to idle pool
  5. alive = alive &&
  6. bodyEOF &&
  7. !pc.sawEOF &&
  8. pc.wroteRequest() &&
  9. replaced && tryPutIdleConn(trace)
  10. if bodyEOF {
  11. eofc <- struct{}{}
  12. }
  13. case <-rc.req.Cancel:
  14. alive = false
  15. pc.t.cancelRequest(rc.cancelKey, errRequestCanceled)
  16. case <-rc.req.Context().Done():
  17. alive = false
  18. pc.t.cancelRequest(rc.cancelKey, rc.req.Context().Err())
  19. case <-pc.closech:
  20. alive = false
  21. }

From this point, we can see that a connection can only be returned to the pool when the bodyEOF signal is received. But how is bodyEOF generated?

Looking further into the code, we can see that there is a wrapped Body object:

  1. [root@liqiang.io]# cat transport.go
  2. body := &bodyEOFSignal{
  3. body: resp.Body,
  4. earlyCloseFn: func() error {
  5. waitForBodyRead <- false
  6. <-eofc // will be closed by deferred call at the end of the function
  7. return nil
  8. },
  9. fn: func(err error) error {
  10. isEOF := err == io.EOF
  11. waitForBodyRead <- isEOF
  12. if isEOF {
  13. <-eofc // see comment above eofc declaration
  14. } else if err != nil {
  15. if cerr := pc.canceled(); cerr != nil {
  16. return cerr
  17. }
  18. }
  19. return err
  20. },
  21. }
  22. resp.Body = body

Then, this wrapped object implements the Read method:

  1. [root@liqiang.io]# cat transport.go
  2. func (es *bodyEOFSignal) Read(p []byte) (n int, err error) {
  3. es.mu.Lock()
  4. closed, rerr := es.closed, es.rerr
  5. es.mu.Unlock()
  6. if closed {
  7. return 0, errReadOnClosedResBody
  8. }
  9. if rerr != nil {
  10. return 0, rerr
  11. }
  12. n, err = es.body.Read(p)
  13. if err != nil {
  14. es.mu.Lock()
  15. defer es.mu.Unlock()
  16. if es.rerr == nil {
  17. es.rerr = err
  18. }
  19. err = es.condfn(err)
  20. }
  21. return
  22. }

So, when we call Read in our business logic, it actually invokes this wrapped object’s Read method. From the implementation, we can see that if an error occurs during reading, it is passed to the fn function for handling.

The logic inside fn determines the type of error and writes the result into a channel. This result is then picked up by the previously mentioned select statement, which checks whether the error is an EOF. If it is, the connection will be returned to the pool.

Server

Compared to the client, Go’s server-side implementation is relatively simple. In terms of the Goroutine model, it follows a Per Request, Per Goroutine approach:

  1. [root@liqiang.io]# cat server.go
  2. func (srv *Server) Serve(l net.Listener) error {
  3. for {
  4. rw, err := l.Accept()
  5. ... ...
  6. connCtx := ctx
  7. ... ...
  8. tempDelay = 0
  9. c := srv.newConn(rw)
  10. c.setState(c.rwc, StateNew, runHooks) // before Serve can return
  11. go c.serve(connCtx)
  12. }
  13. }
  14. func (c *conn) serve(ctx context.Context) {
  15. if ra := c.rwc.RemoteAddr(); ra != nil {
  16. c.remoteAddr = ra.String()
  17. }
  18. ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
  19. var inFlightResponse *response
  20. ... ...
  21. ctx, cancelCtx := context.WithCancel(ctx)
  22. c.cancelCtx = cancelCtx
  23. defer cancelCtx()
  24. c.r = &connReader{conn: c}
  25. c.bufr = newBufioReader(c.r)
  26. c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)
  27. for {
  28. w, err := c.readRequest(ctx)
  29. if c.r.remain != c.server.initialReadLimitSize() {
  30. // If we read any bytes off the wire, we're active.
  31. c.setState(c.rwc, StateActive, runHooks)
  32. }
  33. ... ...
  34. req := w.req
  35. ... ...
  36. c.curReq.Store(w)
  37. ... ...
  38. inFlightResponse = w
  39. serverHandler{c.server}.ServeHTTP(w, w.req)
  40. inFlightResponse = nil
  41. w.cancelCtx()
  42. if c.hijacked() {
  43. return
  44. }
  45. ... ...
  46. w.finishRequest()
  47. c.rwc.SetWriteDeadline(time.Time{})
  48. c.setState(c.rwc, StateIdle, runHooks)
  49. c.curReq.Store(nil)
  50. if !w.conn.server.doKeepAlives() {
  51. return
  52. }
  53. if d := c.server.idleTimeout(); d != 0 {
  54. c.rwc.SetReadDeadline(time.Now().Add(d))
  55. } else {
  56. c.rwc.SetReadDeadline(time.Time{})
  57. }
  58. if _, err := c.bufr.Peek(4); err != nil {
  59. return
  60. }
  61. c.rwc.SetReadDeadline(time.Time{})
  62. }

From this implementation, we can see that server-side connections are also reused, and similar to the client, each connection processes at most one active request at a time. However, not all connections are directly reused, as determined by the shouldReuseConnection function. Below are scenarios where a connection cannot be reused:

  • When the connection is too large or the client has explicitly sent a Connection: Close header.
  • When the response is incomplete (usually due to a write error).
  • When there is a write error in the connection.
  • When the request reading encounters an error.

Practice

Now that we’ve gone through most of the code, it’s time to verify our understanding with a practical example. I have uploaded all the relevant code to a dedicated repository:

🔗 GitHub Repository

If you’re interested, feel free to download and experiment with it yourself!

  1. [root@liqiang.io]# cat main2.go
  2. func main() {
  3. for i := 0; i < 101; i++ {
  4. func() {
  5. resp, err := http.Get("https://gobyexample.com")
  6. if err != nil {
  7. panic(err)
  8. }
  9. defer resp.Body.Close()
  10. fmt.Println("Response status:", resp.Status)
  11. fmt.Println("Connection Header:", resp.Header.Get("Connection"))
  12. _, _ = io.ReadAll(resp.Body)
  13. }()
  14. time.Sleep(time.Millisecond * 500)
  15. }
  16. }

When we run this program and check the number of connections in another terminal, we will notice that there is only one active connection:

  1. [root@liqiang.io]# ss -antp | grep "13.35.238"
  2. ESTAB 0 0 192.168.121.195:44664 13.35.238.63:443 users:(("main",pid=87455,fd=6))

This reveals an interesting fact: even though we call http.Get multiple times, the underlying implementation actually reuses a single DefaultClient. As a result, persistent connections are reused, meaning that by default, Go’s HTTP client leverages long-lived connections.

However, if you forget to close the response body, things might not work as expected. For instance, take a look at the following code—can you spot the issue?

  1. [root@liqiang.io]# cat main.go
  2. func main() {
  3. for i := 0; i < 101; i++ {
  4. func() {
  5. resp, err := http.Get("https://gobyexample.com")
  6. if err != nil {
  7. panic(err)
  8. }
  9. defer resp.Body.Close()
  10. fmt.Println("Response status:", resp.Status)
  11. scanner := bufio.NewScanner(resp.Body)
  12. for i := 0; scanner.Scan() && i < 5; i++ {
  13. fmt.Println(scanner.Text())
  14. }
  15. if err := scanner.Err(); err != nil {
  16. panic(err)
  17. }
  18. }()
  19. time.Sleep(time.Millisecond * 500)
  20. }
  21. }

When we run this program and check the connection status again, we will see that a new connection is created for each request:

  1. [root@liqiang.io]# ss -antp | grep "13.35.238"
  2. TIME-WAIT 0 0 192.168.121.195:59538 13.35.238.114:443
  3. TIME-WAIT 0 0 192.168.121.195:52688 13.35.238.70:443
  4. TIME-WAIT 0 0 192.168.121.195:44890 13.35.238.63:443
  5. TIME-WAIT 0 0 192.168.121.195:54294 13.35.238.22:443
  6. TIME-WAIT 0 0 192.168.121.195:44916 13.35.238.63:443
  7. TIME-WAIT 0 0 192.168.121.195:54306 13.35.238.22:443
  8. TIME-WAIT 0 0 192.168.121.195:44906 13.35.238.63:443

If you analyze the code again using the same logic as before, you will quickly discover the issue. As we previously mentioned, a connection is only returned to the pool when one of the following conditions is met:

  1. An internal processing error occurs (excluding network failures).
  2. The response body has been fully read and an EOF is encountered.

The issue here is that the response body is not fully read before calling Close. Since the connection is not properly released back to the pool, it cannot be reused.

This highlights an important takeaway when writing Go HTTP client code: even if you don’t need the entire response body, it’s best to read it completely before closing. This ensures that the connection can be reused, improving efficiency and reducing unnecessary connection overhead.

Middleware

In real-world applications, we usually do not expose Go’s HTTP client directly to external services. Instead, middleware components such as load balancers (e.g., Nginx) are often placed in between. When using persistent connections, it is important to consider the middleware’s handling of keep-alive connections. Below, we will use Nginx as an example to discuss this aspect.

Nginx

Nginx’s keep-alive parameters can be categorized into two sections:

  1. Nginx <-> Upstream (Backend Servers)
  2. Client <-> Nginx

According to Nginx’s documentation, the key keepalive parameters are as follows:

Connection Parameter Syntax Default Value Description
Client keepalive_disable keepalive_disable none &#124; msie &#124; safari ...; keepalive_disable msie6; Disables keep-alive for specific browsers.
Client keepalive_requests keepalive_requests number; keepalive_requests 1000; Maximum number of requests a single connection can handle.
Client keepalive_time keepalive_time time; keepalive_time 1h; Maximum lifetime of a persistent connection before it is closed.
Client keepalive_timeout keepalive_timeout timeout [header_timeout]; keepalive_timeout 75s; Maximum idle time for a connection. Some browsers support Keep-Alive: timeout=time.
Server (Upstream) keepalive keepalive connections; - Maximum number of connections each worker process can maintain with upstream servers.
Server (Upstream) keepalive_requests keepalive_requests number; keepalive_requests 1000; Maximum number of requests a connection can handle.
Server (Upstream) keepalive_time keepalive_time time; keepalive_time 1h; Maximum lifespan of each connection before it is closed.
Server (Upstream) keepalive_timeout keepalive_timeout timeout; keepalive_timeout 60s; Idle timeout for connections.

References