1. Why timeouts matter
When you write an HTTP client for the first time, it feels simple: make a request — get a response — done. But the network can surprise you. A server can hang, a proxy can be “in a mood”, Wi‑Fi can disappear at the exact moment you hit Enter. And without a timeout, your code can wait for a very long time. Sometimes indefinitely (yes, literally).
A timeout is your rule of politeness in the networking world: “I’ll wait, but not forever.” And it’s not just for user convenience. It’s for the health of your program: if requests hang, they pile up, hold resources (connections, memory, goroutines), and then you wonder why everything became slow and sad.
In Go there are two common layers where we can set a time limit:
- a global client timeout: http.Client.Timeout
- a timeout for a specific request: context.WithTimeout
And today we’ll learn how to choose the right layer and avoid a “timeout salad.”
2. Two levels of timeouts
When an HTTP client shows up in a project, beginners often want to “set a timeout somewhere.” But “somewhere” is like “tighten the bolt with something”: sometimes it works, but usually the furniture suffers. In Go, it’s common to think in layers: there’s a global fuse, and there’s a timeout for a specific operation. These are not competing approaches; they complement each other.
A small comparison table
| Approach | Where it’s set | What it limits in time | When it’s convenient |
|---|---|---|---|
|
in |
the entire request end-to-end (including receiving the response and reading the body) | as baseline protection, so you never wait forever |
|
next to a specific call | a specific operation (a specific request/step) | when different operations have different time budgets |
context has an important idea: contexts form a tree, and cancellation or a timeout “flows down” to child operations. It’s like “the parent said: that’s it, time to go home” — and all the kids stop playing too.
3. http.Client.Timeout: a global fuse
If you describe http.Client.Timeout in plain language, it sounds like this: “This client will not wait longer than N time under any circumstances.” That’s a great first step, because it protects your code from waiting forever even if you forgot about context somewhere.
Example: creating a client with a baseline timeout
package main
import (
"net/http"
"time"
)
func newHTTPClient() *http.Client {
return &http.Client{
Timeout: 2 * time.Second,
}
}
The key engineering idea here: we’re not trying to guess the perfect timeout for every possible request in the universe. We set a baseline safety net so the program doesn’t hang forever.
What exactly Client.Timeout covers
Beginners often think a timeout is “time to first byte.” But in practice, a request has stages: connection, sending the request, waiting for the response, reading the response body. Client.Timeout is a limit for the whole operation. So if the server sends headers quickly but then the body “drips slowly” (or you read it slowly), the timeout can still fire.
That’s often exactly what you want from a “fuse”: either the operation fits into the budget or it doesn’t — and we don’t hang.
4. context.WithTimeout: a timeout for a specific request
context.WithTimeout is a tool for precise time control where a specific operation is executed. It’s especially useful when you have different time expectations for different actions. For example, “check service health” should be fast, while “download a report” can take a bit longer.
Key point: WithTimeout returns not only a new ctx, but also cancel() — and you must call it every time. Cancellation is a completion signal and a way to release resources (including the timer).
Example: a 400ms timeout for a single request
package main
import (
"context"
"net/http"
"time"
)
func doPing(ctx context.Context, client *http.Client, url string) error {
ctx, cancel := context.WithTimeout(ctx, 400*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
Note the order: first we create a context with a timeout, immediately add defer cancel(), then create the request via http.NewRequestWithContext. This matters: the context must be “attached” to the request; otherwise the timeout will exist on its own, and the request will exist on its own (and you’ll genuinely be surprised why nothing got canceled).
5. What happens if you set both timeouts
A very common situation in real code: you have client.Timeout, and you also do context.WithTimeout for a specific operation. A natural question comes up: “Which one wins?”
The earlier deadline wins. In other words: the limit that expires sooner will fire.
Imagine you have two alarms. One is set for 07:00, the other for 07:15. You’ll wake up at 07:00, not wait for the second one.
Example: client 2s, request 300ms
package main
import (
"context"
"net/http"
"time"
)
func doSomething(ctx context.Context, client *http.Client, url string) error {
// Request timeout is smaller than the client's:
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
If the server responds in 800ms, the context timeout (300ms) will fire, even if the client is willing to wait 2 seconds.
6. How to tell a timeout from a cancellation
When a timeout fires, you get an error. And here a beginner often does what any normal person does in a panic: prints err.Error() and starts comparing strings. This works until it doesn’t (usually on a Friday evening).
In Go, it’s idiomatic to distinguish cancellation reasons via errors.Is:
- context.DeadlineExceeded — time is up
- context.Canceled — someone canceled manually (or the parent context finished)
Example: classifying errors with errors.Is
package main
import (
"context"
"errors"
"fmt"
)
func printReason(err error) {
switch {
case errors.Is(err, context.DeadlineExceeded):
fmt.Println("request timeout") // request timeout
case errors.Is(err, context.Canceled):
fmt.Println("request canceled") // request canceled
default:
fmt.Println("other error") // other error
}
}
This is the right style: we make decisions based on the essence of the error, not its text.
Additionally: net.Error and Timeout() bool
In network code, you may sometimes encounter the net.Error interface, which has the method Timeout() bool. This is one way to tell that an error is related to a timeout (or a temporary condition).
This is not a replacement for errors.Is(..., context.DeadlineExceeded), but an additional tool. It’s especially useful when the error is “deep inside” the network stack and you want a more general classification.
package main
import (
"fmt"
"net"
)
func isNetTimeout(err error) bool {
if err == nil {
return false
}
if nerr, ok := err.(net.Error); ok {
return nerr.Timeout()
}
return false
}
func demo(err error) {
fmt.Println(isNetTimeout(err)) // true/false
}
In a real application you usually combine approaches: first use errors.Is for context-related causes, and if needed, additionally check net.Error.
7. Building timeouts into a client for the tasks API
Now let’s do this not just as a standalone demo, but as part of a small evolving application. Suppose we have a client that talks to a tasks API: /api/v1/tasks. We won’t dive into fancy handling of JSON errors and status codes yet — those are separate topics. Our goal right now is to lay timeouts correctly into the client skeleton.
Step 1: client structure with a baseline timeout
package main
import (
"net/http"
"time"
)
type TaskClient struct {
BaseURL string
HTTP *http.Client
}
func NewTaskClient(baseURL string) *TaskClient {
return &TaskClient{
BaseURL: baseURL,
HTTP: &http.Client{
Timeout: 2 * time.Second,
},
}
}
Here we do something simple: the client has baseline protection via Timeout: 2s. Even if someone later forgets about context, the request still won’t hang forever.
Step 2: a method that accepts context.Context
A very good habit in Go is to accept ctx as a parameter instead of creating context.Background() inside the function. That way you let the upper level control time and cancellation. And that’s exactly why context exists: to carry deadlines and cancellation across API boundaries.
package main
import (
"context"
"net/http"
)
func (c *TaskClient) Ping(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
if err != nil {
return err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
For now, the method doesn’t read the body at all. Right now we care more about correct time mechanics than business logic.
Step 3: an internal timeout for a specific operation
Suppose UX requirements say Ping must respond quickly. Then we can do this: the client has a global 2s, but specifically Ping gets 300ms.
package main
import (
"context"
"net/http"
"time"
)
func (c *TaskClient) Ping(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
if err != nil {
return err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
If it feels like “too much protection” — yes, at first it looks like wearing a vest over another vest. But in practice it works great: the global timeout saves you from forgetfulness, and the per-request timeout saves you from slow specific operations.
8. How to choose timeout values
Choosing a timeout isn’t math; it’s an engineering trade-off. Too small a timeout turns the network into “it keeps failing,” too large turns it into “why is it stuck for 40 seconds.” The most practical strategy for learning and for a new project is: set a reasonable global client timeout (for example, 2–5 seconds), and set per-operation timeouts based on UX expectations and the nature of the data.
It helps to mentally split operations into “fast” and “acceptable to be slower.” Checking /health and requesting a list of small tasks are usually fast operations. But requesting a report, export, or analytics can take longer. The key is that the timeout is explicitly set and doesn’t remain “whatever happens.”
One more nuance: timeouts should be measurable and testable. When you debug the client, you’ll thank yourself for having clear time budgets instead of a vague “sometimes it kinda hangs.”
9. Common mistakes
Mistake #1: not setting timeouts at all (“but I’m on localhost”).
On a local machine everything really does fly most of the time. But you’re writing code not “for localhost,” but for a world where the network is chaos and the server can be overloaded. Without a timeout, a request can hang for a long time, and your program will look like it “broke and went silent.”
Mistake #2: using context.WithTimeout, but forgetting cancel().
WithTimeout creates an internal timer and cancellation resources. If you don’t call cancel(), those resources can live longer than necessary. The right discipline is simple: created ctx, cancel := ... — immediately write defer cancel() (even if “it should finish anyway”).
Mistake #3: creating context.Background() inside a client method.
That cuts off the upper layer from time control. As a result, main, a CLI layer, or caller code won’t be able to cancel the operation (for example, on a signal or on an overall scenario timeout). It’s much better to accept ctx as a parameter and work with it.
Mistake #4: trying to detect a timeout by comparing err.Error() strings.
Error text can change, get wrapped, and be extended. For logs, text is fine, but for branching logic use errors.Is(err, context.DeadlineExceeded) and errors.Is(err, context.Canceled). That’s more reliable and more Go-like.
Mistake #5: setting both Client.Timeout and WithTimeout, but not understanding which one actually applies.
In that situation, the earlier deadline fires. If you set client.Timeout = 2s, and WithTimeout(..., 200ms), then the effective timeout will be around 200ms. If that isn’t intentional, you’ll get “weird random timeouts” that are, in fact, not random.
GO TO FULL VERSION