The hanging connection problem
Your service calls an external API. The external server stops responding. Your Go program sits there, waiting for a reply that will never arrive. One goroutine hangs. Then ten. Then a hundred. Each hanging goroutine holds an open TCP socket, a chunk of heap memory, and a file descriptor. Your process memory climbs. Your operating system runs out of sockets. The entire service crashes.
Network code lives in the real world. Real networks drop packets, servers freeze, and firewalls silently discard connections. If you do not tell your program when to stop waiting, it will wait forever. Timeouts are not an optimization. They are a survival mechanism.
How timeouts actually work in Go
A timeout in Go is a cancellation signal that travels through your call stack. You set a deadline. The runtime starts a timer. When the timer fires, it closes a channel. Every function that receives that channel knows to stop what it is doing, clean up resources, and return an error.
Think of it like a kitchen timer on a stove. You set it for ten minutes. When it rings, you take the pot off the heat. You do not keep the burner on and hope the food eventually cooks. The timer forces a decision. Go's context package is that timer. It does not magically kill goroutines. It tells them to stop and return.
The standard library provides three main ways to set timeouts for network work. The http.Client struct has a Timeout field that covers the entire request lifecycle. The context package gives you fine-grained control over deadlines and cancellation. The net package exposes a Dialer type that limits how long a TCP or UDP handshake can take. Each tool covers a different layer of the networking stack.
The minimal setup
Here is the simplest way to attach a timeout to an HTTP request. You create a context with a deadline, defer its cancellation, and pass it to the request builder.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func main() {
// Create a context that auto-cancels after 5 seconds
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// Ensure the timer goroutine is cleaned up when this function returns
defer cancel()
// Build the request and attach the context
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
if err != nil {
fmt.Println("failed to build request:", err)
return
}
// Execute the request with a client that respects the context
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Println("request failed:", err)
return
}
// Close the response body to release the underlying connection
defer resp.Body.Close()
fmt.Println("status:", resp.Status)
}
The context.WithTimeout call returns a context and a cancel function. The cancel function stops the background timer. You always call it with defer to prevent goroutine leaks. The http.NewRequestWithContext function binds the context to the request. When client.Do runs, it monitors the context. If the timer fires before the server responds, the client aborts the connection and returns an error.
Context is plumbing. Run it through every long-lived call site.
What happens under the hood
When you call context.WithTimeout, Go spawns a small background goroutine. That goroutine runs a time.Timer. When the timer expires, the goroutine closes an internal channel and exits. The context object now reports that it is done. Any code calling ctx.Done() or ctx.Err() sees the cancellation immediately.
The HTTP client uses this signal at multiple stages. It checks the context before dialing the remote host. It checks it again during the TLS handshake. It checks it while sending the request headers and body. It checks it while waiting for the response. If the context is already canceled, the client skips the work and returns early. If the context cancels mid-operation, the client closes the underlying network connection and propagates the error up the stack.
This design means you do not need to manually track timeouts at every step. The context carries the deadline automatically. When you pass the context to a database driver, a gRPC call, or a custom TCP client, that library reads the same deadline and stops waiting at the same moment.
The http.Client.Timeout field works differently. It is a blanket limit that covers dial, TLS, request, and response phases. If you set both client.Timeout and a context timeout, whichever expires first wins. The client checks both signals continuously.
Real-world request handling
Production code rarely makes a single request and prints the status. It handles errors, reads response bodies, and chains calls. Here is how a realistic handler looks when timeouts are wired correctly.
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
// FetchData retrieves JSON from a remote endpoint with a strict deadline
func FetchData(ctx context.Context, url string) ([]byte, error) {
// Reuse the incoming context to inherit existing deadlines
client := &http.Client{
// Fallback timeout if the caller forgets to set one
Timeout: 10 * time.Second,
}
// Build the request with the provided context
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
// Execute and handle the response
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
// Always close the body to return the connection to the pool
defer resp.Body.Close()
// Read the body with a reasonable limit
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return body, nil
}
The function accepts ctx context.Context as its first parameter. That is the standard Go convention. Every function that performs I/O should accept a context so callers can control cancellation. The Timeout field on the client acts as a safety net. If a caller passes a context with no deadline, the client still stops waiting after ten seconds. The fmt.Errorf calls wrap errors with context using %w. This preserves the original error type while adding stack information.
When the context expires, client.Do returns an error that satisfies context.DeadlineExceeded. You can check for it explicitly if you want to handle timeouts differently from network failures. The defer resp.Body.Close() call is mandatory. Leaving it open leaks the underlying TCP connection back to the pool.
Pitfalls and runtime errors
Timeouts look simple until they interact with connection pooling, retries, or custom dialers. The most common mistake is ignoring the cancel function. If you call context.WithTimeout but forget defer cancel(), the background timer goroutine stays alive until the deadline passes. In a high-throughput service, thousands of leaked timer goroutines will exhaust your memory. The compiler will not catch this. You will see steady memory growth in your metrics.
Another trap is confusing dial timeouts with read timeouts. The http.Client.Timeout field covers everything. If you only care about how long the TCP handshake takes, you need a custom http.Transport with a DialContext function. Here is how that looks.
package main
import (
"context"
"fmt"
"net"
"net/http"
"time"
)
func main() {
// Configure a transport with a strict dial timeout
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
// Limit the TCP handshake to 2 seconds
dialer := &net.Dialer{Timeout: 2 * time.Second}
return dialer.DialContext(ctx, network, addr)
},
}
// Attach the transport to a client
client := &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
}
// Make a request
resp, err := client.Get("https://example.com")
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()
fmt.Println("status:", resp.Status)
}
The DialContext function replaces the default dialer. It receives the same context you passed to the request. The net.Dialer.Timeout field limits only the connection phase. If the handshake succeeds but the server takes ten seconds to send the first byte, the dial timeout does not trigger. The http.Client.Timeout or a context deadline will catch that instead.
Runtime errors from timeouts follow a predictable pattern. A context deadline expiration returns context deadline exceeded. An HTTP client cancellation returns net/http: request canceled. A dial timeout returns i/o timeout. You can distinguish them by checking the error type or using errors.Is(err, context.DeadlineExceeded). The compiler rejects programs that ignore return values with unused variable or value returned and not used. It also complains with loop variable captured by func literal if you accidentally close over a loop index in a goroutine. Always capture loop variables explicitly or use the function parameter trick.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.
Choosing the right timeout strategy
Network timeouts are not one-size-fits-all. The right choice depends on what layer you are controlling and how you want to handle failures.
Use http.Client.Timeout when you want a simple, blanket limit for the entire request lifecycle. It covers dialing, TLS, sending, and receiving. It requires zero context setup and works out of the box.
Use context.WithTimeout when you need to share a deadline across multiple calls or cancel early based on business logic. It propagates through function boundaries and lets you chain database queries, HTTP calls, and gRPC requests under a single expiration window.
Use net.Dialer.Timeout when you are building a custom TCP or UDP client and only care about the handshake phase. It is the right tool for low-level protocols where you manage the read and write loops yourself.
Use context.WithDeadline when you are chaining calls and want them all to finish by a specific wall-clock time. It is useful for batch jobs or scheduled tasks where absolute time matters more than relative duration.
Use plain sequential code when you do not need concurrency. The simplest thing that works is usually the right thing.