Building an HTTP Server with net/http
Go’s standard library ships a production-capable HTTP server out of the box, in the net/http package. You don’t need a framework to route requests, parse JSON, or serve an API — a few dozen lines of standard Go get you a working server. This lesson builds one from a single "Hello, World" handler up to a JSON API with routing and middleware, and explains what actually happens inside the server when a request arrives.
Overview: How an HTTP Server Works in Go
At the center of net/http is one small interface:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Anything with a ServeHTTP(w http.ResponseWriter, r *http.Request) method satisfies this interface — there is no implements keyword in Go. This is implicit interface satisfaction: the compiler checks the method set of a type against the interface only where the type is actually used as that interface, not where it’s declared. Because writing a full type with a ServeHTTP method is tedious for a simple function, the library provides the adapter type http.HandlerFunc, which is just a function type that also has a ServeHTTP method that calls itself. That’s why you can pass an ordinary function of the signature func(http.ResponseWriter, *http.Request) almost anywhere a Handler is expected.
Two more types matter. http.ResponseWriter is an interface your handler writes the response into: Header() to set response headers, Write([]byte) to write the body, and WriteHeader(statusCode) to send the status line. *http.Request is a struct describing the incoming request: Method, URL (with .Path and .Query()), Header, and Body (an io.ReadCloser).
Requests are routed by an http.ServeMux — a multiplexer that matches the request path against registered patterns and dispatches to the matching handler. http.HandleFunc and http.ListenAndServe(addr, nil) use a package-level http.DefaultServeMux for convenience, which is fine for a quick script, but in real programs prefer creating your own mux := http.NewServeMux() so you aren’t relying on shared global state that any imported package could also register routes on.
Under the hood, http.ListenAndServe(addr, handler) opens a TCP listener with net.Listen, then loops calling Accept() on it. Every time a new connection arrives, the server spawns a new goroutine to handle it, parses the raw bytes into an *http.Request, and calls your handler’s ServeHTTP. This goroutine-per-connection model is why Go servers handle many concurrent clients cheaply — but it also means your handler code can run concurrently on shared data from multiple goroutines at once, so any state a handler mutates outside its own local variables needs explicit synchronization.
Syntax
The general shape of registering and starting a server looks like this:
func handlerName(w http.ResponseWriter, r *http.Request) {
// inspect r.Method, r.URL.Path, r.Header, r.Body
// write a status code and a body through w
}
func main() {
http.HandleFunc("/pattern", handlerName)
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal(err)
}
}
| Piece | Meaning |
|---|---|
http.HandleFunc(pattern, fn) |
Registers fn on the default mux for requests matching pattern |
http.ListenAndServe(addr, handler) |
Starts listening on addr; if handler is nil, http.DefaultServeMux is used |
w.Header().Set(key, value) |
Sets a response header; must be called before Write or WriteHeader |
w.WriteHeader(code) |
Sends the HTTP status line and headers; the first Write call sends an implicit 200 if this wasn’t called |
w.Write(b []byte) |
Writes bytes to the response body |
r.URL.Query().Get(key) |
Reads a URL query-string parameter |
Examples
Example 1: A minimal server
package main
import (
"fmt"
"log"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
}
func main() {
http.HandleFunc("/hello", helloHandler)
fmt.Println("Server listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Output (program’s own stdout):
Server listening on :8080
The program prints its startup line and then blocks forever inside ListenAndServe, serving requests. If you ran this and visited http://localhost:8080/hello in a browser or ran curl http://localhost:8080/hello, the response body sent back over the socket would be Hello, World! — that text never appears in the program’s own stdout because it’s written directly to the client connection, not to the terminal.
Example 2: Routing and method checking with ServeMux
package main
import (
"fmt"
"log"
"net/http"
)
func rootHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Welcome to the home page")
}
func aboutHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
fmt.Fprintln(w, "This is the about page")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", rootHandler)
mux.HandleFunc("/about", aboutHandler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
fmt.Println("Server listening on :8080")
log.Fatal(server.ListenAndServe())
}
Output (program’s own stdout):
Server listening on :8080
This version builds its own http.ServeMux instead of relying on the default one, and wraps it in an explicit http.Server struct, which is where you’d add timeouts in a real deployment. The /about handler checks r.Method itself — a plain http.ServeMux (before Go 1.22’s method-specific patterns) doesn’t filter by HTTP method for you, so a POST /about would otherwise run the same code as a GET. Requesting /about with POST would get back a 405 Method Not Allowed response with the body method not allowed.
Example 3: A small JSON API
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
type Greeting struct {
Message string `json:"message"`
Name string `json:"name"`
}
func greetHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "stranger"
}
greeting := Greeting{
Message: "hello",
Name: name,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(greeting); err != nil {
log.Printf("failed to encode response: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
func main() {
http.HandleFunc("/greet", greetHandler)
fmt.Println("Server listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Output (program’s own stdout):
Server listening on :8080
Note the order inside greetHandler: the Content-Type header is set before anything is written to w, because the first byte written to the body implicitly locks in the status line and headers. Visiting /greet?name=Ada would receive the JSON body {"message":"hello","name":"Ada"}; visiting plain /greet would receive {"message":"hello","name":"stranger"}.
Example 4: Logging middleware
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s took %v", r.Method, r.URL.Path, time.Since(start))
})
}
func pingHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "pong")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/ping", pingHandler)
wrapped := loggingMiddleware(mux)
fmt.Println("Server listening on :8080")
log.Fatal(http.ListenAndServe(":8080", wrapped))
}
Output (program’s own stdout):
Server listening on :8080
Middleware in Go is just a function that takes a Handler and returns a new Handler wrapping it — no special framework mechanism is needed, because Handler is only an interface. Here, every request passing through wrapped runs next.ServeHTTP and then logs how long it took. The timing log goes to log.Printf, which by default writes to stderr, not stdout, so it wouldn’t appear alongside the fmt.Println startup line even though both are visible in a terminal.
How It Works Step by Step
When a client connects and sends a request to a running server, roughly this sequence happens:
- The server’s accept loop (inside
Serve) returns a newnet.ConnfromAccept()and immediately spawns a goroutine to handle it, so the accept loop can go back to waiting for the next connection. - That goroutine reads bytes off the connection and parses them into an
*http.Request: method, URL, headers, and aBodyreader positioned at the start of the request body. - The configured
Handler(your custom mux, orDefaultServeMuxif none was given) is called viaServeHTTP, which looks atr.URL.Pathand finds the most specific registered pattern that matches. - Your handler function runs, reading from
rand writing tow. The first call tow.Writeflushes any headers set so far along with a200 OKstatus, unlessw.WriteHeaderwas already called explicitly with a different code. - Once your handler returns, the server flushes any remaining buffered output and, depending on keep-alive settings, either reuses the connection for the next request or closes it.
Common Mistakes
1. Ignoring the error from ListenAndServe
ListenAndServe only returns when the server stops, and it always returns a non-nil error in that case (for example, the port already being in use). Silently discarding it hides real startup failures.
http.HandleFunc("/", homeHandler)
http.ListenAndServe(":8080", nil) // error is dropped; a bind failure is invisible
Wrap it so the failure is visible immediately:
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatalf("server failed: %v", err)
}
2. Setting headers after the body has already been written
The first Write (including the one inside fmt.Fprintln(w, ...)) locks in the status line and headers. Anything you set on w.Header() afterward is silently ignored.
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello")
w.Header().Set("Content-Type", "text/plain") // too late, has no effect
}
Set headers before writing any body bytes:
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintln(w, "Hello")
}
3. Mutating shared state from handlers without synchronization
Because the server runs each request on its own goroutine, two requests can execute your handler at the same time. Reading and writing a shared variable without protection is a data race.
var hitCount int
func countHandler(w http.ResponseWriter, r *http.Request) {
hitCount++ // unsynchronized read-modify-write, racy under concurrent requests
fmt.Fprintf(w, "hits: %d", hitCount)
}
Guard the shared state with a sync.Mutex:
var (
hitCount int
mu sync.Mutex
)
func countHandler(w http.ResponseWriter, r *http.Request) {
mu.Lock()
hitCount++
current := hitCount
mu.Unlock()
fmt.Fprintf(w, "hits: %d", current)
}
Best Practices
- Configure an explicit
&http.Server{ReadTimeout, WriteTimeout, IdleTimeout}instead of barehttp.ListenAndServefor anything beyond a toy program, to protect against slow or hanging clients. - Build your own
http.NewServeMux()rather than registering onhttp.DefaultServeMux, especially once your program imports other packages that might also register routes globally. - Always check the error returned by
ListenAndServe/ListenAndServeTLS. - Set
Content-Typeand any other headers before the first call toWriteorWriteHeader. - Use
http.Errorfor error responses so the status code and message are set consistently in one call. - Protect any state shared across handlers (counters, caches, connection pools) with a
sync.Mutexor by using channels instead of raw shared variables. - Read
r.Context()in handlers that call slow downstream services, so you can cancel that work if the client disconnects. - Prefer the named
http.StatusOK,http.StatusNotFound, etc. constants over raw numeric status codes. - In a real deployment, listen for
SIGINT/SIGTERMand callserver.Shutdown(ctx)for a graceful shutdown instead of letting connections drop abruptly.
Practice Exercises
- Extend the hello-world server with a new
/timeroute that writes the current time as plain text using thetimepackage. (Hint:time.Now().Format(time.RFC1123).) - Build a small in-memory JSON API:
GET /todosreturns a slice of aTodostruct as JSON, andPOST /todosreads a JSON body withjson.NewDecoder(r.Body).Decode(&todo)and appends it to the slice. Guard the slice with a mutex since both routes can run concurrently. - Add a middleware that returns a custom
404page with a JSON body{"error":"not found"}for any path your mux doesn’t recognize, and verify it by requesting an unregistered path.
Summary
net/httphandlers are anything satisfying theHandlerinterface’sServeHTTP(ResponseWriter, *Request)method — interfaces in Go are satisfied implicitly, with noimplementskeyword.http.HandlerFuncadapts a plain function into aHandler, which is why ordinary functions can be passed toHandleFunc.- An
http.ServeMuxmatches request paths to handlers; prefer creating your own withhttp.NewServeMux()over the sharedhttp.DefaultServeMux. - The server spawns one goroutine per connection, so handler code must treat any shared state as concurrent and protect it accordingly.
- Set headers before writing the response body — the first
Writecall locks in the status line and headers. - Always check the error from
ListenAndServe, and configure explicit timeouts on a customhttp.Serverfor anything running in production.
