The HTTP Client and the Timeout That Isn't There

Making outbound requests with net/http — http.Get versus a configured http.Client, the default client's missing timeout that hangs you forever, closing the body, checking StatusCode, decoding JSON, reusing one client, and per-request deadlines with NewRequestWithContext. Compiled and run against Go 1.26.5.

Sooner or later your service has to call another service — a payment API, an internal microservice, some third party’s REST endpoint. The same net/http package you used to serve requests makes them too, and the basics are genuinely a one-liner. But there is one default in that package dangerous enough that it belongs in the title, because it has taken down production systems that were otherwise carefully written: the out-of-the-box client has no timeout, and a dependency that hangs will hang you with it, forever. This chapter covers the whole client story — the easy calls, the mandatory hygiene, and that missing timeout — verified against a local httptest server so every number below is real. Compiled and run against Go 1.26.5.

The easy call, and the three things you must do

http.Get fires a GET and hands you the response:

resp, err := http.Get(srv.URL + "/books/1")
if err != nil {
	panic(err)
}
defer resp.Body.Close() // 1. always close the body

if resp.StatusCode != http.StatusOK { // 2. check the status yourself
	fmt.Println("unexpected status:", resp.Status)
	return
}

var b Book
json.NewDecoder(resp.Body).Decode(&b) // 3. decode straight from the body
fmt.Printf("got: %+v (status %d)\n", b, resp.StatusCode)
got: {Title:Go in Practice Pages:320} (status 200)

Three habits are non-negotiable, and each maps to a common bug when skipped. Close the body, alwaysdefer resp.Body.Close() right after the error check. The response body is an open connection; leave it unclosed and you leak file descriptors and prevent the connection from being reused, and under load that exhausts your process. Note it needs closing even on a non-2xx response, as long as err was nil. Check StatusCode yourself — a returned err means the request never got an HTTP response at all (DNS failure, connection refused, timeout). A 404 or 500 is a perfectly successful HTTP exchange as far as the client is concerned, so err is nil and you have to inspect resp.StatusCode to notice the server said no. Decode from the body with json.NewDecoder(resp.Body), streaming straight off the wire as the JSON chapter showed.

The timeout that isn’t there

Here is the footgun. http.Get, http.Post, and the package-level http.DefaultClient all have no timeout set. None. If the server accepts your connection and then never responds — a hung database behind it, a network partition, a deadlock — your call to http.Get blocks forever. No error, no return, just a goroutine parked until the heat death of the universe, holding whatever it holds. In a request handler that means a goroutine (and its resources) leaked per stuck call, and a slow-motion outage as they pile up.

The fix is to construct your own http.Client with a Timeout, and to use it instead of the package-level helpers:

client := &http.Client{Timeout: 300 * time.Millisecond}

start := time.Now()
_, err := client.Get(srv.URL) // server here sleeps 2s
fmt.Printf("returned after %v\n", time.Since(start).Round(10*time.Millisecond))
fmt.Println("error:", err)

Pointed at a server that deliberately sleeps two seconds, the timeout fires at 300ms:

configured client returned after 300ms
error: Get "http://127.0.0.1:60939": context deadline exceeded (Client.Timeout exceeded while awaiting headers)

It came back in 300 milliseconds with a real error instead of blocking for two seconds — or forever, had the server never answered. Client.Timeout covers the whole exchange end to end: connection, sending the request, waiting for headers, and reading the entire body. The rule to carry out of this chapter is blunt: never use http.Get or http.DefaultClient in code that matters. Always construct a Client with a Timeout. A bare http.Get is fine for a throwaway script, and a liability anywhere else.

Reuse one client

An http.Client is not a per-request object. It holds a connection pool underneath (the Transport), and reusing a single client across many requests lets Go keep TCP connections alive and reuse them, which skips the handshake cost on every call after the first. Constructing a fresh http.Client{} per request throws that pool away each time and, worse, can leak idle connections. So make one client — often a package-level or struct field — and share it:

// created once, reused for every outbound call
client := &http.Client{Timeout: 5 * time.Second}

The Client is safe for concurrent use by multiple goroutines, so one shared instance is exactly right even in a highly concurrent server. This is the single most common efficiency mistake in Go HTTP code: a Client built inside the function that makes the call, born and discarded per request, pooling nothing.

The pool itself lives one layer down, in the client’s Transport (an *http.Transport when you don’t set one). That’s the object with the knobs — MaxIdleConns, MaxIdleConnsPerHost, IdleConnTimeout — for a service that fans out heavily to one backend and wants a deeper keep-alive pool than the defaults. You rarely touch them early on, but it’s worth knowing where the pooling behavior comes from, because it explains why the sharing matters: a new Client with a fresh default Transport starts with an empty pool every time. One more habit pairs with reuse: fully read and close each response body even when you don’t care about its contents, since a connection with an undrained body can’t be returned to the pool for the next call.

Full control: NewRequest and headers

http.Get and http.Post are conveniences over the real workhorse, client.Do(req), which takes a fully-formed *http.Request. You build one with http.NewRequest when you need to set headers, choose a method, or attach a body — which is most of the time in real code:

payload, _ := json.Marshal(CreateOrder{Item: "A17", Qty: 2})
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/orders", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", "k-123")

resp, err := client.Do(req)
if err != nil {
	panic(err)
}
defer resp.Body.Close()

The body is any io.Reader — here a bytes.Reader over the marshaled JSON. The server sees exactly what we set:

server saw: POST /orders Content-Type="application/json" X-Api-Key="k-123"
client got: 201 {"created":true,"echo":{"item":"A17","qty":2}}

Headers, method, body, all under your control, and the same defer resp.Body.Close() and status check apply.

Per-request deadlines with context

Client.Timeout is a blunt instrument: one duration for every request that client makes. Often you want a deadline for this call — a fast path that must answer in 200ms, a background job that can take a minute — or you want to cancel an outbound request when the inbound request that triggered it goes away. That’s what http.NewRequestWithContext is for. You attach a context with a deadline (or a cancel function), and the request aborts when the context does:

ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
_, err := http.DefaultClient.Do(req) // server sleeps 1s
returned after 200ms
error: Get "http://127.0.0.1:60944": context deadline exceeded
is DeadlineExceeded: true

The request was cancelled at the 200ms context deadline, and errors.Is(err, context.DeadlineExceeded) confirms why it died, which lets you tell a timeout apart from a connection error in your handling. The huge win of the context approach is composition: in a server handler you’d derive the outbound request’s context from the incoming r.Context(), so if the client hangs up or an upstream deadline blows, your outbound call is cancelled automatically instead of soldiering on for a response nobody’s waiting for. Remember to defer cancel() to release the context’s resources whether or not the timeout fires.

Final thoughts

The net/http client is easy to start with and has exactly one trap that will hurt you: http.Get, http.Post, and http.DefaultClient have no timeout, so a hung server hangs your goroutine forever — always build a &http.Client{Timeout: ...} and use it instead. Past that, the hygiene is mechanical and mandatory: defer resp.Body.Close() on every response, check resp.StatusCode yourself because a 500 is not an err, decode straight from resp.Body, and reuse one shared client so the connection pool actually pools. For anything beyond a bare GET, build the request with http.NewRequest to set method, headers, and body, and reach for http.NewRequestWithContext when you want a per-call deadline or want cancellation to flow through from the request that triggered the call. Now that we can talk to servers and be one, the next chapter drops to the layer underneath all of it.

Next: readers and writers everywhere — the io.Reader and io.Writer interfaces that the whole standard library is built on.

Comments