An idle timeout closes a connection after a defined period without qualifying activity. It is a resource policy, not proof that the client disappeared. Quiet connections can belong to healthy clients; the useful question is which activity resets which timer, and what the protocol allows after closure.
Idle is defined at a particular boundary
A TCP connection consumes file descriptors, kernel buffers, and application or proxy bookkeeping. It does not normally require a distinct routing-table entry per connection. Reclaiming unused connections can bound retained resources, but legitimate reuse also avoids setup. Choose the policy for the workload instead of assuming that every quiet connection is abandoned.
Go’s HTTP Server.IdleTimeout bounds waiting for the next request on a keep-alive connection. It does not time out a handler merely because that handler is working without sending bytes. Request-header reading, body transfer, response writing, and application execution need their own relevant controls. A limit with the word “idle” should not be treated as a complete request deadline.
Nginx also distinguishes boundaries: its client keepalive_timeout controls retained client connections, while proxy_read_timeout limits gaps between successive reads from an upstream response, not the whole response duration. These settings observe different activity on different sides of the proxy. Matching their numeric values does not make their meanings identical.
Zero is not portable either. Nginx’s zero keepalive_timeout disables client keep-alive connections. Go’s zero IdleTimeout uses ReadTimeout; when both provide no positive limit, no idle timeout is established. Check the implementation rather than interpreting zero as universally meaning “off” or “immediately close.”
When it breaks
A threshold shorter than legitimate quiet periods forces reconnections or interrupts the relevant flow. An excessively generous one can retain more sockets than the service can afford. Record connection counts, close reasons, reuse, and workload patterns; silence alone does not identify a broken client or establish the best threshold.
HTTP keep-alive means connection reuse, not a periodic application heartbeat. TCP keepalive probes are another mechanism and are not application request bytes. An application heartbeat only affects a timer if that particular endpoint counts it as activity. A proxy, upstream, and client need not observe the same events, especially with streaming or multiplexed protocols.
Closure can race a client starting another request. A stale pooled connection therefore needs a recovery path, but reconnecting does not make every request safe to replay. HTTP explicitly permits asynchronous connection closure; retry decisions still depend on request semantics and the uncertainty about prior effects. “The server closes slightly early” is not automatically harmless for a mutation.
Check it quickly
Save this as idle_probe.go and run go run idle_probe.go. It uses only Go’s standard library and was checked with Go 1.27.0 on macOS. The test server listens on a local loopback address. Requests are plain HTTP/1.1; no public edge, Nginx instance, certificate, or production configuration is involved.
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"sync/atomic"
"time"
)
type connectionKey struct{}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
const idle = 200 * time.Millisecond
const headers = 500 * time.Millisecond
const work = 350 * time.Millisecond
var serial, handled atomic.Int64
closed := make(chan struct{}, 8)
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
if r.URL.Path == "/slow" {
time.Sleep(work)
}
w.Header().Set("X-Connection-ID", strconv.FormatInt(r.Context().Value(connectionKey{}).(int64), 10))
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintln(w, "ok")
}))
server.Config.IdleTimeout = idle
server.Config.ReadHeaderTimeout = headers
server.Config.ConnContext = func(ctx context.Context, _ net.Conn) context.Context {
return context.WithValue(ctx, connectionKey{}, serial.Add(1))
}
server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
if state == http.StateClosed {
closed <- struct{}{}
}
}
server.Start()
defer server.Close()
transport := &http.Transport{MaxIdleConns: 1, MaxIdleConnsPerHost: 1}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
get := func(path string) int64 {
response, err := client.Get(server.URL + path)
check(err == nil, fmt.Sprint(err))
body, readErr := io.ReadAll(response.Body)
closeErr := response.Body.Close()
check(readErr == nil && closeErr == nil, "response body did not finish cleanly")
check(response.Proto == "HTTP/1.1" && response.StatusCode == 200, "unexpected protocol or status")
check(string(body) == "ok\n", "unexpected response body")
id, err := strconv.ParseInt(response.Header.Get("X-Connection-ID"), 10, 64)
check(err == nil, "missing connection identity")
return id
}
first := get("/ping")
check(get("/ping") == first, "the fully consumed responses should allow reuse")
started := time.Now()
check(get("/slow") == first, "the active request should keep its connection")
check(time.Since(started) >= work, "the handler did not complete its work")
fmt.Println("two requests reuse one HTTP/1.1 connection; active work outlasts IdleTimeout")
select {
case <-closed:
case <-time.After(2 * time.Second):
panic("the idle connection was not closed")
}
check(get("/ping") != first, "the request after idle closure needs a new connection")
fmt.Println("server reports idle closure; a later GET succeeds on a new connection")
before := handled.Load()
connection, err := net.DialTimeout("tcp", server.Listener.Addr().String(), time.Second)
check(err == nil, fmt.Sprint(err))
defer connection.Close()
check(connection.SetDeadline(time.Now().Add(2*time.Second)) == nil, "could not bound raw probe")
_, err = io.WriteString(connection, "GET /ping HTTP/1.1\r\nHost: probe\r\n")
check(err == nil, fmt.Sprint(err))
reply, err := io.ReadAll(connection)
check(err == nil && len(reply) == 0, "partial headers should end with EOF in this server")
check(handled.Load() == before, "incomplete headers must not invoke the handler")
fmt.Println("incomplete request headers close without reaching the handler; ReadHeaderTimeout is separate")
fmt.Println(runtime.Version())
}
The client reads each response completely and closes its body, allowing reuse. Two successive requests report the same server-assigned connection identity. A third request runs its handler for 350 milliseconds, longer than the 200-millisecond idle allowance, and still succeeds on that connection. Active handler time is not the between-request idle interval.
After the response finishes, the client sends nothing. The server reports the connection closed, and a later GET succeeds with a different identity. The example observes that transition directly instead of sleeping an assumed amount and declaring that cleanup must have occurred.
A separate raw connection sends an incomplete request header and waits for closure. The tested server returns EOF without invoking the handler; its 500-millisecond ReadHeaderTimeout is distinct from idle reuse policy. Client-side deadlines bound the test’s own waits, and deferred cleanup closes the owned transport and server.
The short durations make the exercise observable, not suitable as universal production values. These checks cover Go’s HTTP/1.1 behavior, not HTTP/2 streams, WebSockets, kernel keepalive timing, or every intermediary. Validate each real hop’s definition of activity before coordinating its idle policy.
Sources
- Go net/http, idle and header timeouts, connection states and response-body reuse, checked 2026-09-09.
- Go net/http/httptest, a task-owned loopback test server and its lifecycle, checked 2026-09-09.
- Nginx HTTP core module, client keep-alive timeout semantics, checked 2026-09-09.
- Nginx proxy module, per-read upstream response timeout, checked 2026-09-09.
- RFC 9112: HTTP/1.1, persistent connections and asynchronous closure, checked 2026-09-09.