net/http

intermediate go stdlib

net/http is Go’s standard library package for building HTTP servers and clients. No framework needed — it ships with the language.

In simple language, we can spin up a production-grade web server with about three lines of code. That’s why Go is loved for backend work.

A server in three lines

The fastest way to get going is http.HandleFunc (register a handler for a path) plus http.ListenAndServe (start listening).

package main

import "net/http"

func main() {
	// register a function to handle requests to "/hello"
	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello!")) // write the response body
	})
	http.ListenAndServe(":8080", nil) // start server on port 8080
}

The nil we pass to ListenAndServe means “use the default router.” More on that below.

The two arguments every handler gets

Every handler receives w http.ResponseWriter and r *http.Request.

  • w is where we write the response — body, status code, headers.
  • r is the incoming request — we read the method, URL, headers, and body from it.
func handler(w http.ResponseWriter, r *http.Request) {
	name := r.URL.Query().Get("name") // read ?name=... from the URL
	w.Header().Set("Content-Type", "text/plain") // set a response header
	w.WriteHeader(http.StatusOK)                  // set status 200
	w.Write([]byte("Hi " + name))                 // write the body
}

One gotcha: we must call w.WriteHeader (and set headers) before writing the body. Once we write the body, the status and headers are locked in.

The http.Handler interface

Under the hood everything is built on one tiny interface:

type Handler interface {
	ServeHTTP(w http.ResponseWriter, r *http.Request)
}

Anything with a ServeHTTP method is a handler. http.HandleFunc is just a convenience — it wraps our plain function into an http.HandlerFunc, which is a type that satisfies this interface. So “a handler function” and “a Handler” are basically the same thing wearing different hats.

ServeMux — the router

http.ServeMux is Go’s built-in request multiplexer (a fancy word for router). It matches the request path to the right handler. The “default router” we passed nil for earlier is just a global ServeMux.

The big news: Go 1.22 upgraded ServeMux to support method and path-variable patterns. Before, it only matched plain prefixes — now it does proper routing.

mux := http.NewServeMux()

// match only GET requests to /users/{id}, with a path variable
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id") // pull the {id} out of the path
	w.Write([]byte("user " + id))
})

http.ListenAndServe(":8080", mux) // pass our mux instead of nil

Before 1.22 we needed a third-party router (gorilla/mux, chi) for this. Now the stdlib covers the common cases.

request flow
HTTP Request ServeMux matched Handler ServeHTTP(w, r)
the mux picks the handler by method + path, then calls its ServeHTTP

Middleware — just handlers wrapping handlers

Go has no special “middleware” concept. Middleware is simply a function that takes a handler and returns a new handler that does extra work before or after calling the original.

In simple language, it’s an onion — each layer wraps the next.

// logging middleware: wraps any handler, logs the method + path, then calls it
func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Printf("%s %s", r.Method, r.URL.Path) // do work before
		next.ServeHTTP(w, r)                       // call the wrapped handler
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("home"))
	})
	http.ListenAndServe(":8080", logging(mux)) // wrap the whole mux
}

We can stack them: auth(logging(mux)). Each one returns a Handler, so they chain cleanly.

The client side

net/http is also a client. http.Get fires off a GET request and gives us a response.

resp, err := http.Get("https://api.example.com/data")
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close() // ALWAYS close the body, or we leak connections

body, _ := io.ReadAll(resp.Body) // read the whole response body
fmt.Println(string(body))

The one rule everyone forgets: always defer resp.Body.Close(). Skip it and we leak network connections.

Interview soundbite

net/http gives us a full HTTP server and client in the standard library, all built on the one-method Handler interface (ServeHTTP). The ServeMux router got a big upgrade in Go 1.22 with method-and-path patterns like "GET /users/{id}". Middleware isn’t a special feature — it’s just a function that wraps a handler and returns a new one, so we chain them like an onion.

References