Go HTTP Server from Scratch
Build a production-ready HTTP server in Go using only the standard library: routing, middleware, timeouts, graceful shutdown, and structured logging.
What you'll learn
- ✓How net/http models handlers and ServeMux
- ✓Writing middleware as handler wrappers
- ✓Setting sensible server timeouts
- ✓Implementing graceful shutdown
- ✓Where to add structured logging and request IDs
Prerequisites
- •Basic Go knowledge and an editor with go modules support
What and Why
Go’s standard library includes a complete HTTP server in net/http. For most services, you do not need a framework: the standard mux (improved in Go 1.22), handler interfaces, and http.Server cover routing, middleware, and lifecycle.
Building from scratch is also a great way to understand what frameworks add on top, so you can evaluate them honestly.
Mental Model
Everything is a http.Handler: a type with a single method ServeHTTP(w ResponseWriter, r *Request). Functions can satisfy it via http.HandlerFunc. Routers are themselves handlers. Middleware wraps a handler and returns a new handler.
incoming request
|
v
http.Server (timeouts, TLS, connection limits)
|
v
Mux (routes by method + path)
|
v
middleware chain (logging, auth, recover)
|
v
endpoint handler
|
v
ResponseWriter -> client Hands-on Example
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
w.Write([]byte("hello " + name))
})
handler := recoverMW(logger)(logMW(logger)(mux))
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Run in a goroutine so we can wait for signals
go func() {
logger.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server error", "err", err)
os.Exit(1)
}
}()
// Graceful shutdown on SIGINT/SIGTERM
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
logger.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("shutdown error", "err", err)
}
}
func logMW(l *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
l.Info("req", "method", r.Method, "path", r.URL.Path,
"dur_ms", time.Since(start).Milliseconds())
})
}
}
func recoverMW(l *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
l.Error("panic", "err", rec, "path", r.URL.Path)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
A lot of small but important things are happening. The Go 1.22 mux supports method-prefixed patterns like GET /hello/{name} and exposes path variables via r.PathValue. Timeouts are set on the server so a slow client cannot pin a goroutine forever. Shutdown waits up to ten seconds for in-flight requests to finish.
Common Pitfalls
- No timeouts: the default
http.Serverhas none. A misbehaving client can keep a connection open indefinitely. Always setReadHeaderTimeoutat minimum. - Goroutine leaks during shutdown: handlers that spawn background goroutines should respect
r.Context(). When the request ends, the context is canceled. http.ListenAndServeblocks: putting it inmaindirectly works, but you cannot capture errors gracefully. Always run it in a goroutine with a clear shutdown path.- Mutating
HeaderafterWriteHeader: headers must be set before the first write. OnceWriteHeaderis called (explicitly or by writing the body), header changes are silently dropped. - Writing huge responses without streaming: build buffered responses defensively. Use
io.Copyfor large payloads instead of loading into memory.
Practical Tips
Use the context package end-to-end. Pass r.Context() to downstream calls (database, HTTP clients) so cancellation propagates. This is the single biggest reliability improvement most services can make.
For request IDs, generate one in middleware, store it in the context, and include it in every log line. Structured logging with log/slog (Go 1.21+) makes this clean.
If you need a richer router (regex routes, host matching, advanced parameter parsing), consider chi or gorilla/mux. The standard mux covers most cases since 1.22.
Run behind a reverse proxy (nginx, Caddy) for TLS and connection limits in production. Even so, set server-level timeouts; defense in depth helps.
Test handlers with httptest.NewRecorder and httptest.NewServer. The standard library makes integration tests trivial.
Wrap-up
You can build a robust HTTP service in Go with just net/http. Set timeouts, wire middleware as handler wrappers, log structured request data, and shut down gracefully with srv.Shutdown. The Go 1.22 mux improvements (method-aware routes, path variables) close the most common gaps. With these patterns, the standard library is more than enough for the vast majority of production workloads.
Related articles
- Go Go Build Tags Explained
Use Go build tags to include or exclude files per OS, architecture, or custom condition. Learn the new //go:build syntax, common patterns, and how tags interact with the test runner.
- Go Go Context Cancellation Patterns
Master Go's context package: propagate deadlines, cancel goroutines safely, and avoid leaks with practical patterns for HTTP, database, and pipeline code.
- Go Go context Package Explained
How to use Go's context package effectively: cancellation, deadlines, propagation, request-scoped values, and the patterns that keep services responsive.
- Go Go database/sql Tutorial
Use Go's standard database/sql package the right way: drivers, connection pools, prepared statements, transactions, context cancellation, and avoiding the classic Rows.Close leak.