Making HTTP Requests with net/http Client

The net/http package’s Client type is how Go programs talk to other servers over HTTP: fetching a web page, calling a REST API, downloading a file, or posting JSON to a service. Every outgoing HTTP request in Go — whether it is a one-line http.Get or a fully customized request with headers, a body, and a deadline — ultimately flows through an http.Client. Understanding how Client, Request, and Response fit together, and how connections are pooled and timed out under the hood, is essential for writing Go programs that talk to the network reliably instead of hanging or leaking resources.

Overview / How the net/http Client Works

An http.Client is a struct with a small set of fields: Transport (how connections are made and reused), CheckRedirect (a hook controlling redirect behavior), Jar (an optional cookie store), and Timeout (an overall deadline for the whole request/response cycle, including redirects and reading the body). The package-level helpers http.Get, http.Post, and http.Head are convenience functions that simply call methods on http.DefaultClient, a zero-value *http.Client shared by the whole program.

The zero value of http.Client is perfectly usable — it works out of the box — but it has one dangerous property: its Timeout is zero, which in Go’s timeout conventions means no timeout at all. If a remote server accepts your connection but never sends a response, a request made with http.DefaultClient (or any client you built without setting Timeout) can block forever. This is why production code almost always constructs its own &http.Client{Timeout: ...} rather than relying on the default.

Under the hood, a Client delegates the actual network work to its Transport, which is usually http.DefaultTransport if you don’t set one. The transport maintains a pool of idle, keep-alive TCP (and TLS) connections keyed by host. When you make a request to a host you’ve already talked to recently, the transport reuses an existing connection instead of paying for a new TCP handshake (and TLS handshake, for HTTPS) every time. This is precisely why you should construct one http.Client and reuse it for many requests, rather than creating a new client for every call — a fresh client with a fresh, unshared transport cannot benefit from connection pooling.

A request is represented by an *http.Request: it carries the HTTP method, the target URL, a header map, and an optional body (an io.Reader, typically wrapped so it also satisfies io.Closer). You build one directly with http.NewRequest (or, better, http.NewRequestWithContext, which ties the request’s lifetime to a context.Context) whenever you need to set custom headers, use a method other than GET, or attach a body. A response comes back as *http.Response, whose Body field is an io.ReadCloser backed directly by the underlying connection — the body is streamed lazily, so you must read it (or at least close it) to free the connection.

Syntax

The general shapes you’ll use, from simplest to most explicit:

resp, err := http.Get(url)
// ...

client := &http.Client{Timeout: d}
req, err := http.NewRequest(method, url, body)
// ...
req.Header.Set(key, value)
resp, err := client.Do(req)
Function / Type Purpose
http.Get(url) Convenience GET using http.DefaultClient; no way to set headers.
http.Post(url, contentType, body) Convenience POST using http.DefaultClient.
http.NewRequest(method, url, body) Builds a *http.Request you can customize before sending.
http.NewRequestWithContext(ctx, method, url, body) Same, but bound to a context.Context for cancellation/deadlines.
client.Do(req) Sends a fully-built request and returns the response.
Client.Timeout Deadline covering the entire round trip: connect, redirects, headers, and body.
Client.Transport Controls connection pooling, proxies, TLS config.
Client.Jar Optional http.CookieJar for automatic cookie handling.

Examples

Example 1: A basic GET request

The examples below spin up a local test server with httptest.NewServer so the program is fully self-contained and its output is exact and reproducible — in real code, server.URL would simply be a live URL like https://api.example.com.

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
)

func main() {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello from the server!")
	}))
	defer server.Close()

	resp, err := http.Get(server.URL)
	if err != nil {
		fmt.Println("request failed:", err)
		return
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Println("read failed:", err)
		return
	}

	fmt.Println("Status:", resp.Status)
	fmt.Println("Body:", strings.TrimSpace(string(body)))
}

Output:

Status: 200 OK
Body: Hello from the server!

The err check happens before we ever touch resp, so defer resp.Body.Close() is only reached once we know resp is non-nil. io.ReadAll drains the body into a byte slice, which we convert to a string to print.

Example 2: A custom Client with headers and a timeout

Real programs rarely use the package-level http.Get, because it gives no way to set headers or a timeout. Instead, build a request and send it through your own *http.Client.

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"time"
)

func main() {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		agent := r.Header.Get("User-Agent")
		fmt.Fprintf(w, "your agent: %s", agent)
	}))
	defer server.Close()

	client := &http.Client{
		Timeout: 5 * time.Second,
	}

	req, err := http.NewRequest(http.MethodGet, server.URL, nil)
	if err != nil {
		fmt.Println("build request failed:", err)
		return
	}
	req.Header.Set("User-Agent", "GoLessonBot/1.0")

	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("request failed:", err)
		return
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Println("read failed:", err)
		return
	}

	fmt.Println(string(body))
}

Output:

your agent: GoLessonBot/1.0

We built the request with http.NewRequest, set a header on it directly, and sent it with client.Do. Because client has a Timeout, this call can never hang indefinitely, unlike a bare http.Get.

Example 3: POST-ing JSON and decoding a JSON response

This example marshals a struct to JSON, sends it as a POST body with a context deadline, and decodes the JSON response straight into another struct.

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

type createUserRequest struct {
	Name string `json:"name"`
}

type createUserResponse struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

func main() {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var req createUserRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(createUserResponse{ID: 42, Name: req.Name})
	}))
	defer server.Close()

	payload, err := json.Marshal(createUserRequest{Name: "Ada"})
	if err != nil {
		fmt.Println("marshal failed:", err)
		return
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, bytes.NewReader(payload))
	if err != nil {
		fmt.Println("build request failed:", err)
		return
	}
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("request failed:", err)
		return
	}
	defer resp.Body.Close()

	var result createUserResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		fmt.Println("decode failed:", err)
		return
	}

	fmt.Printf("Created user #%d: %s\n", result.ID, result.Name)
}

Output:

Created user #42: Ada

http.NewRequestWithContext ties the request to a 5-second context deadline — if that deadline passes before the round trip finishes, client.Do returns an error wrapping context.DeadlineExceeded. On the response side, json.NewDecoder(resp.Body).Decode(&result) streams and parses JSON directly from the network connection without buffering the whole body into memory first, which is the idiomatic way to consume JSON responses.

How It Works Step by Step

When you call client.Do(req) (which is also what http.Get and http.Post do internally), roughly this sequence happens:

  • The Transport looks in its idle-connection pool for a live keep-alive connection to the request’s host and scheme.
  • If none is available, it dials a new TCP connection, and for HTTPS performs a TLS handshake.
  • The request line, headers, and body (if any) are written to the connection.
  • The transport blocks until the response status line and headers arrive, then returns a *http.Response immediately — the body is not fully read yet.
  • Your code reads resp.Body as an io.Reader, pulling bytes off the wire as needed (directly, or via helpers like io.ReadAll or json.Decoder).
  • If Client.Timeout is set, a single deadline covers this entire sequence — connect, write, headers, and reading the body — and the call fails with a timeout error if it’s exceeded.
  • Calling resp.Body.Close() either lets the transport return the now-drained connection to the idle pool for reuse, or closes the underlying connection if it can’t be reused; failing to close the body leaks that connection.
  • By default the client follows up to 10 redirects automatically; supplying CheckRedirect lets you inspect, limit, or block that behavior.

Common Mistakes

Mistake 1: Deferring Close before checking the error

If http.Get or client.Do returns an error, resp is nil. Deferring resp.Body.Close() before the error check dereferences a nil pointer and panics.

// Wrong: panics if err != nil, because resp is nil
resp, err := http.Get(url)
defer resp.Body.Close()
if err != nil {
    log.Fatal(err)
}

Always check the error first, and only defer the close once you know the response is valid:

// Correct
resp, err := http.Get(url)
if err != nil {
    log.Fatal(err)
}
defer resp.Body.Close()

Mistake 2: Treating a nil error as a successful response

A nil error from client.Do only means the round trip completed — it says nothing about the HTTP status code. A 404 or 500 response still comes back with err == nil.

// Wrong: ignores resp.StatusCode entirely
resp, err := http.Get(url)
if err != nil {
    log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))

Check the status code explicitly before trusting the body:

// Correct
resp, err := http.Get(url)
if err != nil {
    log.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
    log.Fatalf("unexpected status: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(body))

Mistake 3: Relying on the default client’s lack of a timeout

Because http.DefaultClient (used by http.Get) has Timeout: 0, a request to a server that accepts the connection but never replies will hang forever, with no way to recover.

// Wrong: no deadline anywhere in this call
resp, err := http.Get("https://example.com/slow")

Give every outbound client an explicit timeout, either on the client itself or via a context:

// Correct
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://example.com/slow")

Best Practices

  • Construct one *http.Client and reuse it for many requests so its Transport can pool and reuse connections.
  • Always set a Timeout on any client you build, or attach a deadline via context.WithTimeout for finer-grained control per request.
  • Check err before touching resp, and check resp.StatusCode before trusting the body — a nil error is not the same as a 2xx response.
  • Always close resp.Body, ideally with defer placed immediately after the nil-error check.
  • Set Content-Type explicitly when sending a request body, since Go does not guess it for you.
  • Prefer http.NewRequestWithContext over http.NewRequest so request cancellation can propagate from a parent operation (like an incoming HTTP handler being canceled by its own client).
  • Decode JSON responses directly with json.NewDecoder(resp.Body).Decode(&v) instead of reading the whole body into memory first, when the response can be large.
  • Avoid using http.DefaultClient directly in production code — build your own so its timeout and transport are under your control.

Practice Exercises

  • Write a program that spins up an httptest.Server returning a JSON array of objects, then use http.Get and json.Decoder to fetch and decode it into a slice of structs, printing the count of items.
  • Take Example 2 and change the handler to call time.Sleep(3 * time.Second) before responding, while keeping the client’s Timeout at a shorter value (e.g. 1 second). Confirm that client.Do now returns a timeout error instead of the response.
  • Write a helper function func postJSON(client *http.Client, url string, v any) (*http.Response, error) that marshals v to JSON, builds a POST request with Content-Type: application/json, and returns the response from client.Do.

Summary

  • http.Client sends requests; http.Get/http.Post are shortcuts that use a shared http.DefaultClient with no timeout.
  • Build custom requests with http.NewRequest or http.NewRequestWithContext to set headers, methods, and bodies before sending them with client.Do.
  • The Transport pools keep-alive connections per host, which is why reusing one Client is more efficient than creating a new one per call.
  • resp.Body streams from the live connection and must always be closed, only after the error from the call has been checked.
  • A nil error does not mean success — always inspect resp.StatusCode.
  • Set an explicit Timeout or use a context deadline on every outbound request in real programs.