Put a Timeout on Every Blocking Call in Your Go Code
Why every blocking call in Go needs a deadline, with runnable examples for HTTP clients, net.Conn, goroutine wrappers and a request-wide time budget.

Most Go outages that involve "everything got slow and then stopped" have the same shape. A downstream service stops answering. A call in your code that has no timeout waits for it. Each incoming request starts a goroutine that makes that call and parks. Goroutines accumulate, memory climbs, the connection pool is exhausted, and the service stops answering as well, long after the original problem has cleared. Nothing in the code was wrong in the sense of a bug; it never decided how long it was willing to wait.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The remedy is a rule applied everywhere: every call that can block on something outside your process has a deadline. This guide covers how to apply the rule to HTTP clients, raw connections and databases, how to write a timeout wrapper that does not leak, and how to pick the numbers. It builds on context, WithTimeout and WithDeadline and goroutine leaks, and belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6.
Why the defaults are dangerous
Go's defaults favour simplicity over safety here. http.Get uses http.DefaultClient, whose Timeout is zero, meaning none. net.Dial has no overall timeout beyond the operating system's connect timeout, which can be minutes. A net.Conn has no read or write deadline until you set one, so conn.Read waits for as long as the peer keeps the connection open and silent. Database calls without a context wait for the driver's own defaults, which are often "forever". The net/http documentation says plainly that a zero Timeout means no timeout.
Each of those is reasonable for a script and dangerous for a server. A server turns every unbounded wait into a goroutine parked for the lifetime of the problem, and since goroutines are cheap there is no early warning: it takes tens of thousands of them before the symptoms are loud.
HTTP clients: three layers of timeout
An outgoing HTTP request passes through phases (dial, TLS handshake, sending the request, waiting for headers, reading the body), and Go offers timeouts at three levels. Which one you reach for depends on what you want to bound.
Client.Timeout bounds the entire exchange, from the start of the request to the end of reading the body. It is the simplest and the one to set on every client you create. A request context (http.NewRequestWithContext(ctx, ...)) lets the caller cancel or time out one specific request, and is how a request-wide deadline reaches the call. Transport fields (DialContext with a net.Dialer{Timeout}, TLSHandshakeTimeout, ResponseHeaderTimeout, IdleConnTimeout) bound individual phases, useful to distinguish "could not connect" from "connected and then silent".
This program starts a local server whose handler takes 400 ms, and calls it with each kind of limit set to 100 ms:
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func describe(err error) string {
switch {
case err == nil:
return "ok"
case errors.Is(err, context.DeadlineExceeded):
return "deadline exceeded: " + err.Error()
default:
return "other: " + err.Error()
}
}
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(400 * time.Millisecond):
fmt.Fprintln(w, "finally")
case <-r.Context().Done(): // the client gave up
}
}))
defer srv.Close()
// 1. Client.Timeout: the whole exchange, including the body.
c1 := &http.Client{Timeout: 100 * time.Millisecond}
_, err := c1.Get(srv.URL)
fmt.Println("Client.Timeout: ", describe(err))
// 2. A request context: the caller decides per request.
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
_, err = http.DefaultClient.Do(req)
fmt.Println("request context: ", describe(err))
// 3. Transport.ResponseHeaderTimeout: only the wait for response headers.
c3 := &http.Client{Transport: &http.Transport{ResponseHeaderTimeout: 100 * time.Millisecond}}
_, err = c3.Get(srv.URL)
fmt.Println("ResponseHeaderTimeout: ", describe(err))
var timeoutErr interface{ Timeout() bool }
fmt.Println("error reports Timeout():", errors.As(err, &timeoutErr) && timeoutErr.Timeout())
}Client.Timeout: deadline exceeded: Get "http://127.0.0.1:57114": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
request context: deadline exceeded: Get "http://127.0.0.1:57114": context deadline exceeded
ResponseHeaderTimeout: deadline exceeded: Get "http://127.0.0.1:57114": net/http: timeout awaiting response headers
error reports Timeout(): trueAll three return after about 100 ms rather than 400, and each reports a timeout. The messages differ, which is useful: Client.Timeout says it was exceeded "while awaiting headers", the context version reports the plain context deadline exceeded, and the transport one says "timeout awaiting response headers". All three satisfy errors.Is(err, context.DeadlineExceeded), and all three report Timeout() true through the net.Error interface, so one check in the caller covers every kind. Note also what the handler does: it watches r.Context().Done(), so the server stops working when the client leaves. That is the other half of the contract: a timeout on the caller is only useful if the callee notices and stops (request context).
A client with no timeout at all is a bug I flag in review every time. &http.Client{Timeout: 10 * time.Second} at construction is one line, it is bounded, and individual calls can still use shorter contexts. The nuance is streaming responses: Client.Timeout includes reading the body, so a long download or an event stream will be cut off. For those, leave Client.Timeout unset and use a context per request plus ResponseHeaderTimeout to bound the part that should be quick.
Build the client once and share it. An http.Client is safe for concurrent use and owns the connection pool, so creating one per request throws away keep-alive connections and makes every call pay for a new TCP and TLS handshake. A package-level or struct-held client with its timeout set at construction is the normal shape. If different callers need different timeouts, keep one client with the generous Timeout and let each caller pass a tighter request context, which wins when it is shorter.
Connections: deadlines are absolute
For raw TCP, a deadline is not a duration but a point in time. conn.SetReadDeadline(t) makes any read after t fail with a timeout error, and it stays in force until you set another, which is the detail that catches people out: setting it once at the start does not give each read its own 5 seconds. For an idle timeout you move the deadline forward before each read:
package main
import (
"errors"
"fmt"
"net"
"os"
"time"
)
func main() {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
go func() { // a server that accepts and then says nothing
c, _ := ln.Accept()
time.Sleep(time.Second)
c.Close()
}()
conn, err := net.DialTimeout("tcp", ln.Addr().String(), time.Second)
if err != nil {
panic(err)
}
defer conn.Close()
buf := make([]byte, 16)
start := time.Now()
conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)) // absolute time, re-armed per read
_, err = conn.Read(buf)
var ne net.Error
fmt.Println("read failed after", time.Since(start).Round(50*time.Millisecond))
fmt.Println("os.ErrDeadlineExceeded:", errors.Is(err, os.ErrDeadlineExceeded))
fmt.Println("net.Error Timeout(): ", errors.As(err, &ne) && ne.Timeout())
}read failed after 150ms
os.ErrDeadlineExceeded: true
net.Error Timeout(): trueThe read gave up after 150 ms instead of waiting for the silent server, and the error satisfies both errors.Is(err, os.ErrDeadlineExceeded) and the net.Error interface's Timeout(). Use the former in new code. For a protocol with idle semantics (a server reading commands), call SetReadDeadline inside the loop, before every read, and the connection is closed if the client goes quiet. The server side has its own version in http.Server's timeout fields (server timeouts), and net.Error timeouts covers how to classify these errors.
A timeout wrapper that doesn't leak
Sometimes the function you call takes no context and has no deadline, say a library call, and you want to bound the wait anyway. The tempting version races the call against a timer:
package main
import (
"context"
"errors"
"fmt"
"runtime"
"time"
)
// withTimeout returns when fn finishes or d passes. fn keeps running if the timer wins.
func withTimeout[T any](d time.Duration, fn func() T) (T, error) {
result := make(chan T, 1) // buffered, so the late goroutine can still finish and exit
go func() { result <- fn() }()
select {
case v := <-result:
return v, nil
case <-time.After(d):
var zero T
return zero, errors.New("timed out")
}
}
// withContext asks fn to stop when ctx ends. This is the version to prefer.
func withContext[T any](ctx context.Context, fn func(context.Context) (T, error)) (T, error) {
return fn(ctx)
}
func slowNoContext() int {
time.Sleep(500 * time.Millisecond) // cannot be interrupted
return 42
}
func slowWithContext(ctx context.Context) (int, error) {
select {
case <-time.After(500 * time.Millisecond):
return 42, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
func main() {
before := runtime.NumGoroutine()
_, err := withTimeout(50*time.Millisecond, slowNoContext)
fmt.Println("wrapper:", err, "| goroutines still running:", runtime.NumGoroutine()-before)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err = withContext(ctx, slowWithContext)
time.Sleep(10 * time.Millisecond)
fmt.Println("context:", err, "| goroutines still running:", runtime.NumGoroutine()-before-1)
time.Sleep(600 * time.Millisecond)
fmt.Println("after the slow call ends, wrapper goroutine gone:", runtime.NumGoroutine()-before == 0)
}wrapper: timed out | goroutines still running: 1
context: context deadline exceeded | goroutines still running: 0
after the slow call ends, wrapper goroutine gone: trueThe wrapper returns after 50 ms, which is what the caller wanted, but one goroutine is still running the 500 ms job; the output shows it, and it goes away only when the job finishes by itself. The result channel being buffered is what lets that goroutine exit without a receiver instead of blocking forever on the send, and getting that detail wrong is how a timeout wrapper turns into a permanent leak. The wrapper is acceptable when the abandoned work is short and bounded; it is not acceptable when the work holds a connection or a lock, or when a hundred timeouts per second each strand a goroutine for a minute. The context-aware version stops the work. That is the version to design for in your own code: every function that can block takes a context.Context and returns promptly when it ends, and the timeout belongs to the caller who passes the context.
One budget for the whole request
Fixed timeouts at each hop add up badly. If a handler calls three downstream services with a 5 second timeout each, the handler can legitimately take 15 seconds, past the point where the client has given up. The better model is a single deadline for the whole request, set at the edge, with each call using whatever is left:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func call(ctx context.Context, name string, cost time.Duration) error {
if dl, ok := ctx.Deadline(); ok {
fmt.Printf(" %-8s starts with %v left\n", name, time.Until(dl).Round(10*time.Millisecond))
}
select {
case <-time.After(cost):
return nil
case <-ctx.Done():
return fmt.Errorf("%s: %w", name, ctx.Err())
}
}
func handle(ctx context.Context) error {
for _, step := range []struct {
name string
cost time.Duration
}{{"auth", 60 * time.Millisecond}, {"database", 80 * time.Millisecond}, {"pricing", 120 * time.Millisecond}} {
if err := call(ctx, step.name, step.cost); err != nil {
return err
}
}
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) // the request budget
defer cancel()
err := handle(ctx)
fmt.Println("result:", err, "| deadline exceeded:", errors.Is(err, context.DeadlineExceeded))
} auth starts with 200ms left
database starts with 140ms left
pricing starts with 50ms left
result: pricing: context deadline exceeded | deadline exceeded: trueThe three steps cost 260 ms in total against a 200 ms budget. Auth starts with about 200 ms left, the database with about 140, and pricing with about 50 ms, and it is pricing that is cut off when the budget runs out, with the error saying which step it was. No step had its own fixed timeout; each saw the same deadline through the context, so the total can never exceed the budget and the wasted work after the client left is bounded. Sub-deadlines are still useful for calls that should fail faster than the whole budget (context.WithTimeout(ctx, 50*time.Millisecond) derives a child that expires at the earlier of the two), and a check such as "if less than 20 ms remain, don't start this call" avoids beginning work that cannot finish.
Cleanup needs separate treatment. If the request context is cancelled, code in a defer that uses it to roll back or release something would fail instantly. Derive a fresh context with context.WithoutCancel and give it its own short timeout for the cleanup (context AfterFunc and WithoutCancel).
Databases and RPC
The same rule applies. database/sql has context variants of every call: QueryContext, ExecContext, BeginTx(ctx, ...). With a driver that supports it, cancelling the context cancels the statement on the server, as covered in cancelling a long-running query. gRPC passes the context deadline to the server in the call metadata, so a deadline set at the edge reaches every service in the chain. Redis, S3, SMTP and similar client libraries nearly all accept a context or a timeout option, and if one does not, treat that as a defect in the choice of library.
Choosing numbers
A timeout is a decision about how long a user or caller is worth keeping waiting, and it has to be informed by measurement. Take the latency distribution of the call in production, find p99 or p99.9, and set the timeout a modest margin above that, so almost all legitimate calls complete and only the stuck ones are cut. A timeout far above p99 protects nothing; one below it turns normal slow calls into errors. Retries multiply the effect, since three attempts at 5 seconds is 15 seconds of waiting, so the retry policy and the budget have to be designed together (retries with backoff).
Treat the timeout error as a normal result. Log it with the operation name, count it in a metric, and decide whether the caller retries, falls back or fails. A timeout that is silently swallowed is the same problem as having none, with a delay.
Testing a timeout
A timeout path that is never exercised is a timeout that does not work. Two techniques keep these tests quick and reliable. For real network code, use httptest.NewServer with a handler that blocks until the request context ends, and give the client a short timeout (50 ms is plenty); the test asserts that the call returned a deadline error in well under a second. Make the handler wait on r.Context().Done() rather than sleeping a fixed time, so the server side finishes the moment the client leaves and the test does not leave a sleeping goroutine behind.
For logic built on context.WithTimeout and timers, Go 1.25's testing/synctest runs the code in a bubble with a fake clock, so a test can wait ten seconds of virtual time in microseconds, with no flakiness from a loaded CI machine (deterministic time tests). Either way, assert on the error kind (errors.Is(err, context.DeadlineExceeded)) and not on the message, since the wording differs between the layers shown above.
Mistakes I keep seeing
A timeout on the client and none on the server. The caller gives up at 2 seconds and the callee keeps working for 30, so retries pile load on a service that is already slow. Pass the context into the work and watch it.
Using time.After in a loop for the timeout. Before Go 1.23 each call left a timer alive until it fired (the time.After leak). A context deadline avoids the issue and also propagates.
Timeout equals retry interval. If the timeout is 2 seconds and the retry is immediate, a slow dependency receives a request every 2 seconds from every caller indefinitely. Add backoff and a cap on attempts.
Resetting the timeout on every retry. The overall budget should shrink as attempts are used. Derive each attempt from the same parent context.
Timing out and then reusing the connection. After a read deadline fires in the middle of a protocol exchange, the stream is in an unknown state. Close the connection or resynchronise; do not send the next command on it.
A review checklist
Search the code for http.Get, http.Post, http.DefaultClient, net.Dial(, sql.Open followed by calls without Context, and http.Server{ with no timeouts. Check each outgoing call for a deadline and each wrapped goroutine for a way to stop. The Go blog's post on context and the net package documentation on deadlines are the sources for the semantics above, and the diagnostics guide covers how to see parked goroutines when you suspect an unbounded wait is already in production.


