Skip to content
C Codeloom
Go

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.

·4 min read · By Codeloom
Intermediate 10 min read

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
Request pipeline

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.Server has none. A misbehaving client can keep a connection open indefinitely. Always set ReadHeaderTimeout at minimum.
  • Goroutine leaks during shutdown: handlers that spawn background goroutines should respect r.Context(). When the request ends, the context is canceled.
  • http.ListenAndServe blocks: putting it in main directly works, but you cannot capture errors gracefully. Always run it in a goroutine with a clear shutdown path.
  • Mutating Header after WriteHeader: headers must be set before the first write. Once WriteHeader is called (explicitly or by writing the body), header changes are silently dropped.
  • Writing huge responses without streaming: build buffered responses defensively. Use io.Copy for 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.