Skip to main content
Go · lesson 23 of 25
Go intermediate Lesson 23 of 25

HTTP Server in Go

A Go HTTP server run for real with curl: ServeMux patterns and the catch-all /, method routing, a JSON API, middleware, request context, graceful shutdown.

By SkillByExample · Updated

The standard library’s net/http is a production-grade server: Go’s own websites and many large services run on it without a framework. Each program below is a real server started in the background and driven with curl on this machine; the transcripts show exactly what came back. Date headers and timings will differ when you run them.

A first server, and the catch-all ”/”

A ServeMux maps URL patterns to handlers; ListenAndServe accepts connections and serves each request in its own goroutine.

package main

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

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "home page, you asked for %s\n", r.URL.Path)
	})
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	fmt.Println("listening on :8081")
	log.Fatal(http.ListenAndServe(":8081", mux))
}
$ go build -o server server.go
$ ./server &
listening on :8081
$ curl -s localhost:8081/health
ok
$ curl -s localhost:8081/
home page, you asked for /
$ curl -s localhost:8081/no/such/page
home page, you asked for /no/such/page

/no/such/page didn’t produce a 404: a pattern ending in / matches every path beneath it, and / is beneath everything. Use the pattern /{$} to match only the root. ListenAndServe only returns on failure, so it is wrapped in log.Fatal; the server runs until the process is stopped (the transcript stops it after the last curl).

Method and wildcard routing (Go 1.22+)

Patterns can name an HTTP method and capture path segments with {name}.

package main

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

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "root only")
	})
	mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "user %s\n", r.PathValue("id"))
	})
	mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "file %s\n", r.PathValue("path"))
	})
	fmt.Println("listening on :8082")
	log.Fatal(http.ListenAndServe(":8082", mux))
}
$ go build -o server server.go
$ ./server &
listening on :8082
$ curl -s localhost:8082/users/42
user 42
$ curl -s localhost:8082/files/docs/2024/report.pdf
file docs/2024/report.pdf
$ curl -s localhost:8082/nope
404 page not found
$ curl -s -i -X DELETE localhost:8082/users/42
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Tue, 06 Oct 2026 18:48:10 GMT
Content-Length: 19

Method Not Allowed

{id} matches one segment and {path...} the rest of the path, read back with r.PathValue. With /{$} for the root, unknown paths now get a real 404. A DELETE on a path that only has a GET route gets 405 Method Not Allowed and an Allow header listing what is allowed — the mux generates that for you. Registering GET also registers HEAD.

A JSON API

A small in-memory store with POST to create and GET to list, returning JSON and proper status codes.

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"sync"
)

type Todo struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

type store struct {
	mu    sync.Mutex
	todos []Todo
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func (s *store) create(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB cap
	var in struct {
		Title string `json:"title"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title required"})
		return
	}
	s.mu.Lock() // handlers run concurrently
	t := Todo{ID: len(s.todos) + 1, Title: in.Title}
	s.todos = append(s.todos, t)
	s.mu.Unlock()
	writeJSON(w, http.StatusCreated, t)
}

func (s *store) list(w http.ResponseWriter, r *http.Request) {
	s.mu.Lock()
	defer s.mu.Unlock()
	writeJSON(w, http.StatusOK, s.todos)
}

func main() {
	s := &store{todos: []Todo{}}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /todos", s.create)
	mux.HandleFunc("GET /todos", s.list)
	fmt.Println("listening on :8083")
	log.Fatal(http.ListenAndServe(":8083", mux))
}
$ go build -o server server.go
$ ./server &
listening on :8083
$ curl -s localhost:8083/todos
[]
$ curl -s -i -X POST localhost:8083/todos -d '{"title":"write lesson"}'
HTTP/1.1 201 Created
Content-Type: application/json
Date: Tue, 06 Oct 2026 18:48:11 GMT
Content-Length: 32

{"id":1,"title":"write lesson"}
$ curl -s -X POST localhost:8083/todos -d '{"title":"run it"}'
{"id":2,"title":"run it"}
$ curl -s -w '%{http_code}\n' -X POST localhost:8083/todos -d '{"titel":"typo"}'
{"error":"title required"}
400
$ curl -s localhost:8083/todos
[{"id":1,"title":"write lesson"},{"id":2,"title":"run it"}]

writeJSON sets the header before WriteHeader; headers set afterwards are ignored, because the status line and headers have already been sent. Initialising todos to an empty slice makes the first GET return [] rather than null. The misspelled key decoded into an empty Title and was rejected with 400. The mutex is required: the server runs every request on its own goroutine, so two POSTs can arrive at once.

Middleware: logging with the status code

http.ResponseWriter doesn’t expose the status a handler wrote, so logging middleware wraps it.

package main

import (
	"log"
	"net/http"
	"os"
	"time"
)

type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

func logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(sw, r)
		log.Printf("%s %s %d %v", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Microsecond))
	})
}

func main() {
	log.SetOutput(os.Stderr)
	mux := http.NewServeMux()
	mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hi\n")) })
	log.Fatal(http.ListenAndServe(":8084", logRequests(mux)))
}
$ go build -o server server.go
$ ./server 2> server.log &
$ curl -s localhost:8084/hello
hi
$ curl -s localhost:8084/missing
404 page not found
$ cut -c21- server.log
GET /hello 200 7µs
GET /missing 404 26µs

The server’s log went to server.log, printed after the requests (cut drops the date and time stamp). The default status is 200 because a handler that never calls WriteHeader sends 200 on its first Write; the mux’s 404 went through the wrapper’s WriteHeader and was recorded. Embedding http.ResponseWriter in statusWriter promotes all its other methods, so the wrapper still satisfies the interface.

Request context: noticing a client that left

r.Context() is cancelled when the client disconnects, so long-running handlers can stop early.

package main

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

func report(w http.ResponseWriter, r *http.Request) {
	select {
	case <-time.After(3 * time.Second): // stand-in for a slow query
		fmt.Fprintln(w, "report ready")
	case <-r.Context().Done():
		log.Printf("client gave up: %v", r.Context().Err())
	}
}

func main() {
	http.HandleFunc("GET /report", report)
	log.Fatal(http.ListenAndServe(":8085", nil))
}
$ go build -o server server.go
$ ./server 2> server.log &
$ curl -s --max-time 1 localhost:8085/report; echo "curl exit code $?"
curl exit code 28
$ cut -c21- server.log
client gave up: context canceled

curl gave up after one second (exit code 28 is curl’s timeout), the server saw the connection close, cancelled the request’s context, and the handler stopped instead of finishing three seconds of useless work. Pass r.Context() to database/sql queries and outgoing http.NewRequestWithContext calls and they get the same cancellation for free. Passing nil as the handler uses http.DefaultServeMux, which http.HandleFunc registers on — fine for small programs, but an explicit mux avoids global state.

Graceful shutdown

srv.Shutdown stops accepting new connections and waits for in-flight requests to finish.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
		log.Print("slow request started")
		time.Sleep(time.Second) // simulated work, still in flight at shutdown
		fmt.Fprintln(w, "slow request finished")
	})
	srv := &http.Server{Addr: ":8086", Handler: mux, ReadHeaderTimeout: 5 * time.Second}

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()
	go func() {
		if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()

	<-ctx.Done() // wait for a signal
	log.Print("signal received, shutting down")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Print("forced shutdown: ", err)
	}
	log.Print("bye")
}
$ go build -o server server.go
$ ./server 2> server.log &
$ curl -s localhost:8086/slow &
$ kill -TERM %1
$ wait
slow request finished
$ cut -c21- server.log
slow request started
signal received, shutting down
bye

The SIGTERM (what Docker and Kubernetes send on stop) arrived while /slow was still running. Shutdown let it finish — curl still received its response — and only then did main log bye and exit. Without this, killing the process would cut the request off mid-response. ReadHeaderTimeout is set because the zero-value http.Server has no timeouts at all, which lets slow clients hold connections open indefinitely.

Practice

1. Add DELETE /todos/{id} to the JSON API that returns 204 No Content when it removes a todo and 404 otherwise. Test it with httptest instead of a running server.

Solution
package main

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

type store struct {
	mu    sync.Mutex
	todos map[int]string
}

func (s *store) remove(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.todos[id]; err != nil || !ok {
		http.NotFound(w, r)
		return
	}
	delete(s.todos, id)
	w.WriteHeader(http.StatusNoContent)
}

func main() {
	s := &store{todos: map[int]string{1: "write lesson"}}
	mux := http.NewServeMux()
	mux.HandleFunc("DELETE /todos/{id}", s.remove)

	for _, path := range []string{"/todos/1", "/todos/1", "/todos/abc"} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest("DELETE", path, nil))
		fmt.Println(path, rec.Code)
	}
}
$ go run p1.go
/todos/1 204
/todos/1 404
/todos/abc 404

The second delete finds nothing and gets 404; a non-numeric id is treated the same way. Calling mux.ServeHTTP directly runs the real routing, PathValue included, without a network.

2. Start the basic server’s mux with httptest.NewServer and fetch /health with http.Get, printing the status and body.

Solution
package main

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

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	srv := httptest.NewServer(mux) // real server on a random local port
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/health")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	fmt.Printf("%s %q\n", resp.Status, body)
}
$ go run p2.go
200 OK "ok\n"

Unlike httptest.NewRecorder, NewServer goes through a real TCP connection, so it also tests things like headers set by the server and client timeouts.

3. Write middleware that adds an X-Request-ID header (a counter is fine) to every response, and check it with curl -i.

Solution
package main

import (
	"fmt"
	"log"
	"net/http"
	"strconv"
	"sync/atomic"
)

var nextID atomic.Int64

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Request-ID", strconv.FormatInt(nextID.Add(1), 10))
		next.ServeHTTP(w, r)
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hi") })
	log.Fatal(http.ListenAndServe(":8087", requestID(mux)))
}
$ go build -o server server.go
$ ./server &
$ curl -s -i localhost:8087/ | grep -i -e x-request-id -e '^hi'
X-Request-Id: 1
hi
$ curl -s -i localhost:8087/ | grep -i -e x-request-id -e '^hi'
X-Request-Id: 2
hi

The header is set before calling next, while headers can still be changed. Each request gets the next number, so a fresh server answers with 1, then 2. atomic.Int64 keeps the counter safe across concurrent requests.

Frequently Asked Questions

Do I need a router library for Go HTTP servers?
Often not. Since Go 1.22 the standard http.ServeMux matches methods and path wildcards, as in mux.HandleFunc("GET /users/{id}", h) with r.PathValue("id"), and answers 405 with an Allow header for a wrong method. Libraries such as chi still help with route groups and middleware stacks.
Why does my "/" handler answer every URL?
A ServeMux pattern ending in a slash matches every path under it, and "/" is under everything, so it catches all requests no other pattern matches. Use "/{$}" to match only the root path, or check r.URL.Path and return http.NotFound yourself.
How do I parse JSON request bodies in Go?
Wrap the body with http.MaxBytesReader to cap its size, then decode with json.NewDecoder(r.Body).Decode(&v) and return 400 Bad Request if it fails. Calling DisallowUnknownFields on the decoder also rejects misspelled keys instead of silently ignoring them.
How does request context work in a Go handler?
Every request carries r.Context(), which the server cancels when the client disconnects or the request finishes. Pass it to database queries and outgoing HTTP calls, and select on ctx.Done() in long work, so abandoned requests stop consuming resources.
How do I shut down a Go HTTP server gracefully?
Catch SIGINT and SIGTERM, for example with signal.NotifyContext, then call srv.Shutdown(ctx). Shutdown stops accepting new connections and waits for in-flight requests to finish, up to the deadline in ctx, after which ListenAndServe returns http.ErrServerClosed.