Writing an HTTP Server in Go

Build an HTTP server with net/http, use the Go 1.22 router with methods and path values, return JSON, set server timeouts, and shut down cleanly.

Go can serve production HTTP traffic with only the standard library. Since Go 1.22, the built-in router also handles methods and path parameters, which used to be the main reason people reached for a framework. This page builds a small JSON API and covers the settings that the quick examples online usually leave out.

The Smallest Server

// norun
package main

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

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "hello")
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Open http://localhost:8080 and you see hello. A handler gets two things: w to write the response and r with everything about the request. ListenAndServe only returns on an error, such as the port already being in use, so wrapping it in log.Fatal makes that visible.

Do not ship this version:

This uses the default server, which has no timeouts. A slow or malicious client can hold connections open forever. The timeout section below shows the fix.

Methods and Path Values

Patterns can include an HTTP method and named parts of the path:

package main

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

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "user %s\n", r.PathValue("id"))
	})
	mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusCreated)
		fmt.Fprintln(w, "created")
	})

	for _, tc := range []struct{ method, path string }{
		{"GET", "/users/7"},
		{"POST", "/users"},
		{"DELETE", "/users/7"},
		{"GET", "/nope"},
	} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
		fmt.Printf("%s %s -> %d %s", tc.method, tc.path, rec.Code, rec.Body.String())
	}
}
GET /users/7 -> 200 user 7
POST /users -> 201 created
DELETE /users/7 -> 405 Method Not Allowed
GET /nope -> 404 404 page not found

r.PathValue("id") reads the {id} part. A request with the wrong method gets 405 Method Not Allowed automatically. Using your own http.NewServeMux() instead of the global http.HandleFunc keeps routes in one place and makes testing easy, as shown with httptest here.

Returning JSON and Errors

Set the header, then the status, then write the body. The order matters:

package main

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

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 getBook(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id != "1" {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "book not found"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"id": id, "title": "The Go Programming Language"})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /books/{id}", getBook)

	for _, path := range []string{"/books/1", "/books/9"} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		fmt.Print(rec.Code, " ", rec.Header().Get("Content-Type"), " ", rec.Body.String())
	}
}
200 application/json {"id":"1","title":"The Go Programming Language"}
404 application/json {"error":"book not found"}

superfluous response.WriteHeader call:

Headers and the status are sent the first time you call Write or WriteHeader. Setting a header after that does nothing, and calling WriteHeader again logs http: superfluous response.WriteHeader call. The usual cause is a missing return after writing an error, so the handler keeps going and writes a second response.

Timeouts and Graceful Shutdown

A real server uses an http.Server with timeouts and shuts down without cutting off requests in progress:

// norun
package main

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

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("ok"))
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       120 * time.Second,
	}

	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()
	log.Println("listening on :8080")

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	<-ctx.Done()

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Println("shutdown:", err)
	}
	log.Println("stopped")
}
  • ReadHeaderTimeout limits how long a client may take to send headers, which blocks the "slowloris" attack.
  • ReadTimeout and WriteTimeout cap the time for reading the whole request and writing the response. Raise WriteTimeout for endpoints that stream large files.
  • IdleTimeout closes keep-alive connections that sit unused.
  • On Ctrl+C or SIGTERM, Shutdown stops accepting new connections and waits for running requests to finish, up to 10 seconds.

ListenAndServe returns http.ErrServerClosed after Shutdown. That is the normal path, not a failure, which is why it is filtered out.

Your server is production shaped:

If your server uses its own mux, sets the four timeouts, returns after writing errors, and handles SIGTERM with Shutdown, it is ready to run behind a load balancer or in a container.

Summary

Use http.NewServeMux with patterns like GET /users/{id} and read path parts with r.PathValue. In handlers, set headers before calling WriteHeader, and always return after writing an error. Never deploy the default server: create an http.Server with timeouts and stop it with Shutdown so requests finish cleanly.