The missing page problem
You build a small web service. You test the happy path. It works. Then you type a wrong URL in the browser. The browser shows a raw HTML page with plain text. Or worse, your server panics and crashes because a handler tried to read a nil pointer. Go does not hand you a pretty error page out of the box. It gives you a blank canvas and expects you to draw the boundaries yourself.
How Go routes requests
Go's standard library treats HTTP routing as a simple dispatch table. The net/http package ships with http.ServeMux, a multiplexer that matches incoming request paths to registered handler functions. If a path matches, the handler runs. If nothing matches, the multiplexer falls back to a default handler. That default handler is http.NotFound, which writes a 404 status and a short text message to the response writer. You can replace that fallback. You can also intercept errors from your own handlers before they reach the client. The core idea is that every HTTP response flows through a chain of responsibilities. You control the chain.
Go follows a strict convention for web handlers. Every handler implements the http.Handler interface, which requires a single method: ServeHTTP(http.ResponseWriter, *http.Request). The standard library provides http.HandlerFunc, a type adapter that lets you pass plain functions to routing methods without writing boilerplate. This keeps your code readable while preserving the interface contract. Accept interfaces, return structs. The http package lives by this rule.
Go gives you the steering wheel. You decide where the car stops.
A minimal 404 handler
Here's the simplest way to replace the default missing page: create a multiplexer, register your routes, and add a wildcard catch-all at the end.
package main
import (
"log"
"net/http"
)
func main() {
// Create a multiplexer to route requests to handlers
mux := http.NewServeMux()
// Handle the root path with a simple response
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Home page"))
})
// Catch every unmatched route and return a custom 404
mux.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404: Nothing here"))
})
// Listen on port 8080 and log any fatal startup errors
log.Fatal(http.ListenAndServe(":8080", mux))
}
The wildcard /* route matches any path that does not match a more specific registration. The multiplexer evaluates exact matches first, then longest prefix matches, and finally falls through to the wildcard. You place the catch-all last so it never shadows your real routes. The WriteHeader call sets the HTTP status code before any body bytes are sent. If you skip it, Go defaults to 200 OK, which confuses browsers and API clients.
What happens under the hood
When a request arrives, the ServeMux locks its internal routing tree and searches for a match. It reads the request method and path, then dispatches to the first matching handler. The handler receives a http.ResponseWriter and a *http.Request. The response writer is a buffered stream. It holds headers in memory until you call WriteHeader or Write. The first call to Write automatically flushes headers if they haven't been sent yet. Once the body starts flowing, the status code is locked. You cannot change it afterward.
This buffering behavior is intentional. It lets you decide the status code dynamically based on logic inside the handler. It also means you must be careful about the order of operations. Writing body bytes before setting a 404 or 500 status will silently downgrade the response to 200 OK. The client receives success headers with error content. That breaks HTTP semantics and breaks tools that rely on status codes for routing or retry logic.
Headers travel first. Once the body starts flowing, the status is locked.
Realistic error handling
Production applications rarely return plain text errors. They render HTML templates, return JSON payloads, or log structured error details. You can wrap your multiplexer in a middleware function that catches panics, logs failures, and formats responses consistently.
// PanicRecovery wraps a handler to catch panics and return a 500 response
func PanicRecovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
// Recover from any panic triggered by the downstream handler
if err := recover(); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
}
}()
// Pass the request to the next handler in the chain
next.ServeHTTP(w, r)
})
}
You apply the wrapper before starting the server. The middleware runs first, sets up the recovery guard, then delegates to your multiplexer. If a handler panics, the defer block catches it, sets the correct status, and returns a safe message. The server stays alive. You can extend this pattern to parse templates, inject request IDs, or write to a central error log.
When working with templates, the community accepts the if err != nil boilerplate because it makes the unhappy path visible. Template parsing fails fast. You check the error, log it, and return a 500 response. You do not swallow template errors. You also follow the convention that context.Context always goes as the first parameter in functions that need cancellation or deadlines. Request handlers receive r.Context(), which carries the client connection lifecycle. Respect it. Cancel long operations when the client disconnects.
Middleware is a safety net. Catch the fall before it hits the client.
Common traps and compiler feedback
Go's compiler catches many mistakes before runtime, but HTTP handlers introduce subtle pitfalls that only appear when traffic flows.
If you pass a raw function to http.ListenAndServe without wrapping it in http.HandlerFunc, the compiler rejects the program with cannot use handler (type func(http.ResponseWriter, *http.Request)) as type http.Handler in argument. The ListenAndServe function expects an http.Handler, not a function signature. Wrap it or use mux.HandleFunc.
If you forget to import a package, you get undefined: pkg from the compiler. If you import one and never use it, you get imported and not used. Go enforces clean dependency graphs. Remove unused imports before building.
Runtime panics are the real danger. Dereferencing a nil pointer, accessing a map with a missing key, or dividing by zero will crash the goroutine handling that request. In a standard net/http server, a panic in a handler terminates only that request goroutine. The server process survives. The client receives a broken connection or a generic 500 response. You must recover from panics explicitly. The defer recover() pattern is the standard guard.
Another trap is writing headers after the body. If you call w.Write first, then call w.WriteHeader(404), the second call does nothing. The headers already flushed. You end up sending a 200 OK response with error content. Always set the status code before writing any body bytes.
The compiler catches type mismatches. Runtime panics catch you off guard. Write the unhappy path first.
When to use which approach
Use http.NotFound when you want the standard library's default behavior and are prototyping. Use a wildcard route (/*) on http.ServeMux when you need a simple custom 404 page without extra dependencies. Use a middleware wrapper when you want to catch panics, log requests, or inject headers across all routes. Use a dedicated error handler package when your application requires templated responses, localization, or structured logging. Use plain sequential routing when you don't need global error interception: explicit route handlers are easier to test.
Pick the simplest chain that covers your failure modes.