HTTP and Networking

Updated

September 13, 2026

HTTP and Networking

net/http is the client and the server. The boring default is a ServeMux with method-and-path patterns (Go 1.22+), a handler that writes a status and a body, and tests against httptest.Server so the process exits. ListenAndServe is what you run in production. It is not what you run in a go run example unless you shut it down.

Mental model

A client sends a request (http.Get, http.NewRequest + Client.Do) and must close resp.Body. A server registers handlers on a mux. Go 1.22 patterns look like "GET /tickets/{id}". r.PathValue("id") is the wildcard.

httptest.NewServer(h) listens on a local port, gives you srv.URL, and srv.Close() shuts it down. That is the shape of every complete program in this chapter that you are expected to run.

http.ListenAndServe(addr, mux) blocks forever (or until a fatal error). Use it in main of a real service, with http.Server and Shutdown when you have a signal. Do not leave it as the only example you type.

Worked examples

Case 1: httptest server and GET pattern

Save as ticket_get.go. The server runs, the client fetches /tickets/7, both finish, the process exits.

// ticket_get.go
package main

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /tickets/{id}", func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id")
        fmt.Fprintf(w, "ticket %s\n", id)
    })
    srv := httptest.NewServer(mux)
    defer srv.Close()

    resp, err := srv.Client().Get(srv.URL + "/tickets/7")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("status", resp.StatusCode)
    fmt.Print(string(body))
}

Run:

go run ticket_get.go

Output:

status 200
ticket 7

srv.Client() talks to this server and ignores HTTP proxies. Prefer it over http.Get in listings. The {id} wildcard only matches a single path segment. GET in the pattern means a POST will not hit this handler.

Case 2: POST and a method miss

Save as ticket_post.go. Register POST /tickets. A GET to the same path is 405.

// ticket_post.go
package main

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /tickets", func(w http.ResponseWriter, r *http.Request) {
        raw, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        fmt.Fprintf(w, "accepted %s\n", raw)
    })
    srv := httptest.NewServer(mux)
    defer srv.Close()
    c := srv.Client()

    resp, err := c.Post(srv.URL+"/tickets", "text/plain", strings.NewReader("7"))
    if err != nil {
        fmt.Println(err)
        return
    }
    body, err := io.ReadAll(resp.Body)
    resp.Body.Close()
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("POST", resp.StatusCode, strings.TrimSpace(string(body)))

    bad, err := c.Get(srv.URL + "/tickets")
    if err != nil {
        fmt.Println(err)
        return
    }
    io.Copy(io.Discard, bad.Body)
    bad.Body.Close()
    fmt.Println("GET", bad.StatusCode)
}

Run:

go run ticket_post.go

Output:

POST 200 accepted 7
GET 405

405 means the path exists for another method. 404 means no pattern matched.

Case 3: Client with a timeout

Save as client_timeout.go. A Client is not the default. It has a timeout so a stuck server cannot hold main forever.

// client_timeout.go
package main

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(50 * time.Millisecond)
        fmt.Fprintln(w, "done")
    })
    srv := httptest.NewServer(mux)
    defer srv.Close()

    c := srv.Client()
    c.Timeout = 10 * time.Millisecond
    _, err := c.Get(srv.URL + "/slow")
    fmt.Println(err != nil)
}

Run:

go run client_timeout.go

Output:

true

The error text includes a URL and context deadline exceeded (wording can include the client timeout). Checking err != nil is the stable part. Raise Timeout above 50ms and you get false.

Case 4: httptest.NewRecorder without a listener

Save as handler_only.go. When you only need to test a handler, skip the server.

// handler_only.go
package main

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

func ticket(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "ticket %s\n", r.PathValue("id"))
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /tickets/{id}", ticket)
    req := httptest.NewRequest("GET", "/tickets/7", nil)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)
    fmt.Println(rec.Code)
    fmt.Print(rec.Body.String())
}

Run:

go run handler_only.go

Output:

200
ticket 7

No port, no goroutine. Use this in unit tests. Use NewServer when you want the real client stack.

The trap

ListenAndServe never returns on success. This program is complete and you should not leave it running as your only check.

// hang.go
package main

import (
    "fmt"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "ok")
    })
    fmt.Println("listening on :8080")
    http.ListenAndServe("127.0.0.1:8080", mux)
}

If you run it, it prints listening on :8080 and waits. Stop it with Ctrl-C. The production shape that still exits in a listing is http.Server plus Shutdown:

// shutdown.go
package main

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "ok")
    })
    ln, err := net.Listen("tcp", "127.0.0.1:0")
    if err != nil {
        fmt.Println(err)
        return
    }
    srv := &http.Server{Handler: mux}
    go srv.Serve(ln)

    resp, err := http.Get("http://" + ln.Addr().String() + "/health")
    if err != nil {
        fmt.Println(err)
        return
    }
    body, _ := io.ReadAll(resp.Body)
    resp.Body.Close()
    fmt.Print(string(body))

    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        fmt.Println(err)
    }
}

Run:

go run shutdown.go

Output:

ok

Prefer Case 1 (httptest) for everyday examples. Use Shutdown when you are showing a real http.Server.

The boring rule

  • Patterns: "GET /tickets/{id}", not a manual method check unless you have to.
  • PathValue for wildcards.
  • Close the response body. Use a Client with a Timeout.
  • httptest.Server or NewRecorder in programs and tests that must exit.
  • ListenAndServe in production main, with Shutdown on signal.
  • Do not ignore ListenAndServe’s error (http.ErrServerClosed after Shutdown is expected).

Try this

  1. In ticket_get.go, request /tickets/7/extra. Print the status (404).
  2. Add mux.HandleFunc("GET /tickets/{id}/note", ...) and fetch that path.
  3. In client_timeout.go, set Timeout to 200 * time.Millisecond and print the body when err == nil.
  4. In handler_only.go, send a POST with NewRequest. Print rec.Code (405).