TCP keepalive asks whether the other TCP endpoint still responds after a quiet period. It does not ask whether an HTTP handler, database query, or worker is making progress. A peer’s kernel can acknowledge traffic while its application is stalled. Treat transport reachability and useful application responses as different observations.

A transport probe, not an application heartbeat

Keepalive can expose a connection whose remote state disappeared or whose path silently stopped delivering traffic. The implementation sends probes after sufficient inactivity and eventually reports failure after its configured unanswered-probe policy. A single lost probe is not proof of a dead peer; transient loss must not be mistaken for certainty.

These probes are handled below the application’s byte stream. They are not HTTP requests, database pings, or WebSocket heartbeats. If a proxy terminates the TCP connection, the peer being probed is that proxy, not the origin behind it. Probes may affect some middlebox tracking, but cannot be assumed to refresh an application’s idle timer or preserve every NAT mapping.

Already outstanding data has its own retransmission and failure rules. Do not translate a keepalive setting into a universal deadline for busy connections. A FIN, reset, application timeout, and silent network loss are different events and may require different recovery decisions.

Configure the socket you actually use

RFC 1122 requires an optional keepalive mechanism to default off, with a configurable default idle interval of at least two hours. Applications and libraries can explicitly enable it and choose shorter intervals; Go’s dialer is one example. Therefore kernel defaults alone do not establish an application’s effective behavior.

On Linux, tcp_keepalive_time, tcp_keepalive_intvl, and tcp_keepalive_probes describe idle seconds, probe spacing, and the unanswered-probe limit. The documented defaults are 7200, 75, and 9. Per-socket TCP_KEEPIDLE, TCP_KEEPINTVL, and TCP_KEEPCNT can specialize the policy when SO_KEEPALIVE is enabled. These names and interfaces are not universal macOS or Windows settings.

Choose intervals against detection needs, connection count, transient-loss tolerance, and intermediary policies. An idle-plus-probe arithmetic estimate is not a hard real-time guarantee. Prefer scoped socket configuration over changing machine-wide defaults for unrelated services, and check setter errors and the actual enabled state.

When it breaks

A supposedly protected request may hang because only keepalive was configured, with no application read deadline. Conversely, an application deadline can expire while the TCP connection remains usable. Neither result proves that the peer process died. Retrying a write after a local timeout can duplicate work that the peer has already received.

A lower idle timeout at a proxy can still close a connection regardless of the endpoint’s keepalive policy. Excessively frequent probes also add traffic and power cost across large fleets. Diagnose the actual terminating hop and failure signal before changing all three timers together.

Check it quickly

Save this as keepalive_probe.go and run go run keepalive_probe.go with Go 1.23 or newer on macOS or Linux. The SO_KEEPALIVE inspection uses a Unix socket API; this exact program is not a Windows recipe. It creates only loopback sockets and changes no sysctl, firewall, or shared service.

package main

import (
	"errors"
	"fmt"
	"io"
	"net"
	"os"
	"runtime"
	"syscall"
	"time"
)

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func keepalive(connection *net.TCPConn) int {
	raw, err := connection.SyscallConn()
	must(err)
	var value int
	var optionError error
	must(raw.Control(func(fd uintptr) {
		value, optionError = syscall.GetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_KEEPALIVE)
	}))
	must(optionError)
	return value
}

func main() {
	listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
	must(err)
	defer listener.Close()
	must(listener.SetDeadline(time.Now().Add(2 * time.Second)))
	dialer := net.Dialer{Timeout: 2 * time.Second, KeepAlive: -1}
	connection, err := dialer.Dial("tcp4", listener.Addr().String())
	must(err)
	client := connection.(*net.TCPConn)
	defer client.Close()
	peer, err := listener.AcceptTCP()
	must(err)
	defer peer.Close()

	check(keepalive(client) == 0, "keepalive must start disabled")
	must(client.SetKeepAliveConfig(net.KeepAliveConfig{
		Enable: true, Idle: 30 * time.Second,
		Interval: 10 * time.Second, Count: 3,
	}))
	enabled := keepalive(client)
	check(enabled != 0, "SO_KEEPALIVE must be enabled")
	fmt.Printf("SO_KEEPALIVE: 0 -> %d (enabled); per-socket configuration accepted\n", enabled)

	must(client.SetWriteDeadline(time.Now().Add(2 * time.Second)))
	n, err := client.Write([]byte("ping"))
	must(err)
	check(n == 4, "request must be fully written")
	must(client.SetReadDeadline(time.Now().Add(200 * time.Millisecond)))
	reply := make([]byte, 4)
	n, err = client.Read(reply)
	check(n == 0 && errors.Is(err, os.ErrDeadlineExceeded), "silent application must reach read deadline")
	fmt.Println("read deadline fired while the peer application was silent")

	must(peer.SetDeadline(time.Now().Add(2 * time.Second)))
	request := make([]byte, 4)
	_, err = io.ReadFull(peer, request)
	must(err)
	check(string(request) == "ping", "the original request is still queued")
	n, err = peer.Write([]byte("pong"))
	must(err)
	check(n == 4, "reply must be fully written")
	must(client.SetReadDeadline(time.Now().Add(2 * time.Second)))
	_, err = io.ReadFull(client, reply)
	must(err)
	check(string(reply) == "pong", "same connection must deliver the reply")
	fmt.Println("after resetting the read deadline, the original connection returns pong")
	fmt.Println(runtime.Version(), runtime.GOOS)
}

The dialer first disables keepalive. Explicit socket configuration then enables it, and getsockopt must return a nonzero value; enabled need not equal one. The checked macOS Go 1.27.0 run returned eight. The peer application deliberately leaves the four-byte request unread while the client’s 200-millisecond read deadline expires.

Afterward, the peer reads the original ping and writes pong. Resetting the client’s read deadline permits that reply on the same connection, without reconnecting or resending. The timeout bounded a local wait; it did not withdraw queued bytes or cancel remote work.

All assertions passed. This short experiment checks socket configuration and application-deadline behavior, not actual keepalive probes: the configured first-probe idle interval is thirty seconds. It neither drops packets nor measures failure detection through a NAT. Those require a separately controlled network test with packet evidence and recovery checks.

Further reading

Sources

  • RFC 1122: Requirements for Internet Hosts, section 4.2.3.6: optional keepalive, inactivity, and lost probes, checked 2026-09-09.
  • Linux tcp(7), keepalive defaults, socket options, and shorter intermediary timeouts, checked 2026-09-09.
  • Go net, KeepAliveConfig, socket configuration, and deadline semantics, checked 2026-09-09.