The silent hang
You write a function to fetch a JSON payload from a third-party API. The code compiles. The local test passes. You deploy it to production and everything runs smoothly for three days. Then the external service experiences a network blip. The TCP connection stalls. Your Go program keeps the goroutine alive, waiting for a response that will never arrive. One request hangs. Then ten. Then a hundred. The goroutine count climbs until the process exhausts its memory and the OS kills it.
The culprit is almost always the same: you used http.Get or http.DefaultClient without setting a timeout. The standard library ships with a client that waits indefinitely. This is not a bug. It is a deliberate design choice that forces you to make a decision about how long your program is willing to wait for a network call.
Why the default client waits forever
Go's standard library favors explicit behavior over hidden defaults. Network calls are inherently unreliable. Packets drop. Servers crash. DNS resolvers time out. If the standard library picked a default timeout of thirty seconds, your batch processor might fail on a slow but valid request. If it picked five seconds, your file upload would abort prematurely. The library refuses to guess. It hands you the steering wheel and expects you to set the speed limit.
Think of a default HTTP client like a phone that never stops ringing. You dial a number. The other side does not answer. The phone keeps ringing until you hang up. The operating system does not decide when to stop. You do. In Go, the http.Client type is that phone. The Timeout field is the hang-up button.
The default client lives in the net/http package as http.DefaultClient. It is a package-level variable initialized with zero values for all timeout fields. Zero means infinity in this context. Every call to http.Get, http.Post, or http.PostForm uses that global client under the hood. If you never create your own http.Client, you are inheriting the infinite wait. The compiler will not warn you. It sees valid types and correct syntax. The failure only appears when the network misbehaves.
Setting a hard timeout
The simplest fix is to instantiate a client with a blanket timeout. The Timeout field measures the entire round trip from DNS resolution to the final byte of the response body. It covers the TCP handshake, TLS negotiation, request writing, and response reading.
Here is the minimal pattern for a fixed timeout:
package main
import (
"fmt"
"net/http"
"time"
)
func main() {
// Timeout covers DNS, TCP, TLS, request, and response reading
client := &http.Client{
Timeout: 10 * time.Second,
}
// Use the configured client instead of the global http.Get
resp, err := client.Get("https://httpbin.org/delay/5")
if err != nil {
fmt.Printf("request failed: %v\n", err)
return
}
// Always close the body to return the connection to the pool
defer resp.Body.Close()
fmt.Printf("status: %s\n", resp.Status)
}
The Timeout field is a convenience wrapper. Under the hood, it creates a context with a deadline and passes it to the transport layer. When the deadline passes, the transport cancels the dial, aborts the TLS handshake, or cuts the read loop. The Get method returns immediately with a *url.Error that wraps context.DeadlineExceeded.
This approach works well for scripts, CLI tools, and services that talk to a single external system with predictable latency. You create the client once, usually at startup, and reuse it across the entire program. Reusing clients is essential because http.Client maintains a connection pool. Creating a new client for every request destroys the pool and forces a fresh TCP handshake each time. The pool keeps idle connections alive so the next request can skip the dial phase. A timeout prevents the pool from filling up with half-open connections that never finish.
Never mutate http.DefaultClient at runtime. Assigning http.DefaultClient.Timeout = 30 * time.Second in main works, but it changes a global variable that other packages might depend on. The standard library exports http.DefaultClient as a convenience, not as a configuration object. Create your own http.Client and pass it explicitly. It is safer and easier to test.
Set the timeout once. Reuse the client forever.
Per-request control with context
Real applications rarely talk to just one service. A health check endpoint should fail fast in two seconds. A database backup upload might need thirty. A blanket client timeout forces you to pick the lowest common denominator, which usually means either aborting slow but valid requests or waiting too long for fast ones.
The solution is per-request timeout control using context. Context propagates deadlines, cancellation signals, and request-scoped values through the call stack. The HTTP client respects context deadlines automatically.
Here is how you attach a deadline to a single request:
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func fetchWithDeadline() {
// Context carries a two-second deadline for this specific call
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// Releases the timer and clears the context tree when done
defer cancel()
// Attach the context to the request before sending
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://httpbin.org/delay/5", nil)
if err != nil {
fmt.Printf("failed to build request: %v\n", err)
return
}
// DefaultClient respects the context deadline automatically
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("request aborted: %v\n", err)
return
}
// Return the connection to the pool even on success
defer resp.Body.Close()
fmt.Printf("status: %s\n", resp.Status)
}
The context.WithTimeout call returns a derived context and a cancellation function. The derived context holds a timer that fires after two seconds. When the timer fires, the context is marked as done. The HTTP transport detects the done signal and tears down the connection. The Do method returns with an error.
Always call cancel() when the request finishes, even if it succeeds. The context might hold resources like a timer or a background goroutine. Calling cancel stops the timer and clears the context tree. The defer cancel() pattern guarantees cleanup regardless of how the function exits.
Go convention places context.Context as the first parameter of any function that might perform I/O. If you wrap the HTTP call in a helper function, the signature should look like func Fetch(ctx context.Context, url string) (*http.Response, error). This keeps the cancellation path visible and consistent across your codebase. Functions that take a context should respect cancellation and deadlines. The community expects this pattern everywhere.
What breaks when you get it wrong
Forgetting a timeout does not trigger a compiler error. The compiler sees valid types and correct syntax. It has no way to know that your program will eventually run out of memory. The failure happens at runtime, usually under load or during an outage.
The most common symptom is a goroutine leak. Each hanging request spawns a goroutine that waits on a channel or a network read. The goroutine never returns. The memory allocator keeps the stack frames alive. After a few hundred leaked goroutines, the program hits the OS memory limit and crashes. The runtime prints fatal error: out of memory or runtime: goroutine stack exceeds limit when the allocator cannot grow the stack.
When a timeout actually fires, the error message tells you exactly what happened. The HTTP client returns a *url.Error that wraps the underlying cause. If the context deadline passed, the error chain contains context deadline exceeded. If the dial phase timed out, you see i/o timeout. You can unwrap the error to check the specific cause:
import "errors"
if err != nil {
// Check if the failure was caused by a timeout or cancellation
if errors.Is(err, context.DeadlineExceeded) {
fmt.Println("request took too long")
} else {
fmt.Printf("network error: %v\n", err)
}
}
Another trap is ignoring the response body. If you do not read the body to completion and close it, the underlying connection cannot be reused. The client will eventually drop it, but you waste bandwidth and delay the next request. The defer resp.Body.Close() pattern handles this, but you must still read the body if you need the data. Draining the body with io.Copy(io.Discard, resp.Body) is a common pattern when you want to reuse the connection but do not need the payload.
The worst goroutine bug is the one that never logs. Silent hangs do not panic. They do not print stack traces. They just consume memory until the process dies. Always wrap external HTTP calls in a timeout, and always log the error when the call fails.
Picking the right timeout strategy
Use a custom http.Client with a fixed Timeout when your application talks to a single external service with predictable latency and you want to avoid passing context through every function. Use context.WithTimeout when different endpoints require different deadlines, or when you need to cancel a request from a higher-level handler like an HTTP server or a CLI interrupt. Use http.DefaultClient only for quick scripts, one-off tooling, or internal calls where a missing timeout will not cause resource exhaustion. Use a client with explicit Transport configuration when you need fine-grained control over dial timeouts, TLS handshakes, or connection pooling behavior.
Context is plumbing. Run it through every long-lived call site.