I added a dial timeout to an HTTP client and assumed I had bounded the entire request. Then a server accepted the connection, sent headers, and dribbled the body slowly enough to occupy a worker for minutes. The timeout had completed its duty before the interesting failure began.

In Go 1.3, the first choice for a simple end-to-end limit is http.Client.Timeout:

client := &http.Client{Timeout: 5 * time.Second}

It covers connection setup, redirects, and reading the response body. If the policy really is “this request gets five seconds,” start there. My narrower transport setting looked like this:

tr := &http.Transport{
	Dial: (&net.Dialer{
		Timeout: 2 * time.Second,
	}).Dial,
}
client := &http.Client{Transport: tr}

The dial timeout covers establishing the network connection. It does not cover waiting for response headers, reading a response body, or the time spent following redirects. DNS resolution may consume part of dialing behavior depending on the platform and resolver path, but the timeout is still not an end-to-end request deadline.

To see the phases, I wrote a deliberately bad server:

func slow(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain")
	w.WriteHeader(http.StatusOK)
	if f, ok := w.(http.Flusher); ok {
		f.Flush()
	}
	time.Sleep(10 * time.Second)
	fmt.Fprintln(w, "eventually")
}

The client connected immediately and received headers immediately. Its Do call returned a response, then reading resp.Body waited. A connect timeout could not help because the connection was exemplary.

The transport exposes other phase-specific controls. ResponseHeaderTimeout bounds the wait for response headers after the request is written. TLSHandshakeTimeout bounds the TLS handshake. Neither bounds a slowly arriving body. These knobs are useful because failures differ: a slow handshake may indicate different capacity trouble than a handler that never writes headers.

For policies that Client.Timeout cannot express, I can arrange cancellation through the transport’s request cancellation support and a timer. Merely returning from a selecting caller while leaving Do running leaks work: timeout handling must interrupt the network operation, not only stop waiting for its answer.

For direct net.Conn code, deadlines are clearer:

if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
	return err
}

The deadline applies to future reads and writes and causes them to fail after the time. SetReadDeadline and SetWriteDeadline split those directions. A deadline is an absolute time, not an idle timeout that automatically moves after every successful byte. For an idle protocol I refresh it after each accepted unit of progress.

HTTP complicates direct connection deadlines because http.Transport owns and reuses pooled connections. A per-request caller should not reach underneath and set arbitrary deadlines on a connection that may later serve another request. Transport-level controls and cancellation preserve that ownership boundary.

As with the earlier runtime-managed network mechanism, a goroutine can wait without occupying a thread. Cheap waiting is not a timeout policy, though. In-flight requests, response body size, queue length, and elapsed time still need deliberate limits.

Servers have the same issue from the opposite side. A handler can be tied up by a client that sends or receives slowly. Depending on the Go version and server configuration, read and write timeouts establish connection-level deadlines around phases of serving a request. They are blunt instruments, especially for streaming responses, but leaving every deadline at zero means “forever.”

I tested the repaired client with three bad endpoints: one that never completed a TCP connection, one that accepted but withheld headers, and one that streamed a byte periodically. Each exercises a different phase. The output made the distinction visible:

dial stall:    timeout awaiting connection
header stall:  timeout awaiting response headers
body stall:    request deadline reached while reading body

Error strings vary and should not become program logic. I care that each operation terminates, closes its body or cancellation path, and leaves no steadily growing goroutine count.

The temptation is to choose one five-second number and call the system safe. Real policies differ. A metadata request might have a strict total deadline. A large download may allow minutes overall but reject 30 seconds without progress. A streaming endpoint may be intentionally unbounded while still needing heartbeat and cancellation rules.

Timeout errors should remain distinguishable from protocol failures. I check whether a returned network error reports Timeout() rather than matching its text, then include the operation and peer in the higher-level error. Retrying blindly is still wrong: a timed-out request may have reached the server, so non-idempotent operations need application-level rules.

“The HTTP timeout” is not a very useful phrase. There are limits for connecting, handshaking, receiving headers, reading bodies, remaining idle, and completing the whole operation. Naming the phase put the mechanism in the right place; before that, I had a very reliable timeout on the part that was not slow.