use http.DefaultServeMux in production. It is a global variable, which causes race conditions during parallel testing and makes dependency injection impossible. Creating a local mux instance ensures test isolation and explicit route registration.
2. http.Server Wrapper: http.ListenAndServe is a convenience function that hides critical configuration. Wrapping the server in an http.Server struct allows explicit control over read/write timeouts, idle timeouts, and handler assignment.
3. Handler Struct Pattern: Attaching handlers to a struct enables dependency injection (database clients, loggers, configuration) without relying on global state. This pattern scales cleanly as services grow.
4. Context-Aware Response Writing: Always check r.Context().Done() before performing blocking operations. This prevents goroutine leaks when clients disconnect mid-request.
Implementation
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// ServiceConfig holds runtime parameters for the HTTP server.
type ServiceConfig struct {
Port string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownDelay time.Duration
}
// Application encapsulates dependencies and routing logic.
type Application struct {
config ServiceConfig
logger *log.Logger
}
// NewApplication initializes the service with explicit configuration.
func NewApplication(cfg ServiceConfig) *Application {
return &Application{
config: cfg,
logger: log.New(os.Stdout, "[HTTP-SVC] ", log.LstdFlags|log.Lmicroseconds),
}
}
// RegisterRoutes builds an isolated mux and attaches handlers.
func (app *Application) RegisterRoutes() *http.ServeMux {
mux := http.NewServeMux()
// Health check endpoint
mux.HandleFunc("/health", app.handleHealthCheck)
// Data endpoint with JSON response
mux.HandleFunc("/api/v1/status", app.handleStatusReport)
// Fallback for undefined routes
mux.HandleFunc("/", app.handleNotFound)
return mux
}
// handleHealthCheck returns a lightweight 200 OK for load balancers.
func (app *Application) handleHealthCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
}
// handleStatusReport demonstrates structured JSON response writing.
func (app *Application) handleStatusReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Respect client disconnection
ctx := r.Context()
select {
case <-ctx.Done():
app.logger.Println("client disconnected before response")
return
default:
}
payload := map[string]interface{}{
"service": "core-api",
"version": "1.0.0",
"uptime": time.Since(time.Now()).String(),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(payload); err != nil {
app.logger.Printf("failed to encode response: %v", err)
}
}
// handleNotFound catches unregistered paths.
func (app *Application) handleNotFound(w http.ResponseWriter, r *http.Request) {
http.Error(w, "route not registered", http.StatusNotFound)
}
// Start configures and launches the HTTP server with lifecycle management.
func (app *Application) Start() error {
mux := app.RegisterRoutes()
server := &http.Server{
Addr: fmt.Sprintf(":%s", app.config.Port),
Handler: mux,
ReadTimeout: app.config.ReadTimeout,
WriteTimeout: app.config.WriteTimeout,
IdleTimeout: app.config.IdleTimeout,
}
// Graceful shutdown listener
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
app.logger.Printf("listening on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
app.logger.Fatalf("server failed: %v", err)
}
}()
<-quit
app.logger.Println("shutdown signal received")
ctx, cancel := context.WithTimeout(context.Background(), app.config.ShutdownDelay)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
app.logger.Fatalf("graceful shutdown failed: %v", err)
}
app.logger.Println("server stopped cleanly")
return nil
}
func main() {
cfg := ServiceConfig{
Port: "8080",
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
ShutdownDelay: 15 * time.Second,
}
app := NewApplication(cfg)
if err := app.Start(); err != nil {
os.Exit(1)
}
}
Why These Choices Matter
- Timeout Configuration:
ReadTimeout prevents slowloris attacks by limiting how long the server waits for request headers. WriteTimeout caps response generation time, preventing goroutine pileups from slow database queries. IdleTimeout cleans up keep-alive connections that are no longer active.
- Explicit Mux Registration:
http.NewServeMux() creates a routing table scoped to this instance. This eliminates global state pollution and allows you to swap routers during testing or mount sub-routers for versioned APIs.
- Context Propagation: Checking
ctx.Done() before blocking operations ensures that if a client drops the connection, the handler exits immediately rather than continuing to allocate resources for a dead request.
- Graceful Shutdown: The
signal.Notify + server.Shutdown pattern allows in-flight requests to complete before the process exits. This is mandatory for Kubernetes deployments, where pods receive SIGTERM and have a configurable termination grace period.
Pitfall Guide
1. Relying on http.ListenAndServe Without Timeouts
Explanation: The convenience function creates a server with zero timeouts. Malicious or misbehaving clients can hold connections open indefinitely, exhausting file descriptors and memory.
Fix: Always instantiate http.Server explicitly and set ReadTimeout, WriteTimeout, and IdleTimeout based on your service’s SLA.
2. Using the Default Multiplexer Globally
Explanation: http.DefaultServeMux is shared across all packages that import net/http. This causes route collisions in large codebases and makes unit testing non-deterministic due to parallel execution.
Fix: Create http.NewServeMux() per service or per test suite. Pass the mux explicitly to handlers or server configuration.
3. Ignoring Request Body Limits
Explanation: Reading an unbounded request body allows attackers to consume server memory. http.Request.Body streams data, but without limits, a single request can trigger OOM conditions.
Fix: Wrap the body with http.MaxBytesReader(w, r.Body, maxBytes) before reading. Typical limits range from 1MB to 10MB depending on the endpoint.
4. Blocking Handlers Without Context Awareness
Explanation: Handlers run in separate goroutines. If a handler performs a long-running database query or external API call without checking r.Context().Done(), it continues executing even after the client disconnects.
Fix: Pass r.Context() to downstream calls. Use select statements to abort work when the context is canceled.
5. Missing Graceful Shutdown Logic
Explanation: Sending SIGKILL or exiting immediately drops active connections. Load balancers mark the instance as unhealthy, causing request failures during deployments.
Fix: Listen for SIGINT/SIGTERM, call server.Shutdown(ctx) with a reasonable deadline, and allow in-flight requests to finish before terminating.
6. Overcomplicating Routing with Regex
Explanation: The standard library’s pattern matching supports static paths and trailing wildcards. Developers often pull in regex routers for simple prefix matching, adding unnecessary CPU overhead.
Fix: Use mux.HandleFunc("/api/v1/", handler) for prefix routing. Reserve regex or parameterized routing for cases where path variables are strictly required.
Explanation: Browsers and API clients rely on Content-Type to parse responses correctly. Omitting it forces clients to guess, leading to rendering issues or failed JSON parsing.
Fix: Always set w.Header().Set("Content-Type", "application/json") (or appropriate MIME type) before writing the response body.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal microservice or proxy | net/http stdlib | Minimal overhead, full control, no external dependencies | Lower compute/memory costs |
| High-throughput public API | net/http + custom middleware | Predictable latency, easier profiling, no framework abstraction tax | Reduced infrastructure scaling needs |
| Rapid prototyping / hackathon | Third-party framework (Gin/Echo) | Faster route definition, built-in validators, developer convenience | Higher binary size, slower cold starts |
| Team with limited Go experience | Framework with strong documentation | Lower learning curve, opinionated structure, community examples | Increased maintenance burden, dependency drift |
| Serverless / edge deployment | net/http stdlib | Smaller binary, faster initialization, better cold-start metrics | Direct cost savings per invocation |
Configuration Template
Copy this template into main.go to establish a production-ready baseline. Adjust timeouts and ports to match your deployment environment.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
type Server struct {
addr string
readTimeout time.Duration
writeTimeout time.Duration
idleTimeout time.Duration
shutdownWait time.Duration
handler http.Handler
}
func NewServer(addr string, h http.Handler) *Server {
return &Server{
addr: addr,
readTimeout: 5 * time.Second,
writeTimeout: 10 * time.Second,
idleTimeout: 120 * time.Second,
shutdownWait: 15 * time.Second,
handler: h,
}
}
func (s *Server) Run() error {
srv := &http.Server{
Addr: s.addr,
Handler: s.handler,
ReadTimeout: s.readTimeout,
WriteTimeout: s.writeTimeout,
IdleTimeout: s.idleTimeout,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("starting server on %s", s.addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
<-quit
log.Println("shutdown initiated")
ctx, cancel := context.WithTimeout(context.Background(), s.shutdownWait)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("shutdown failed: %v", err)
}
log.Println("server terminated gracefully")
return nil
}
Quick Start Guide
- Initialize the module: Run
go mod init http-service in your project directory.
- Create the entry file: Save the configuration template above as
main.go.
- Add a router: Create a separate
router.go file that returns http.NewServeMux() with your handlers attached.
- Wire it together: In
main(), instantiate the router, pass it to NewServer(":8080", mux), and call .Run().
- Validate: Execute
go run . and test endpoints with curl -v http://localhost:8080/health. Verify timeout behavior by sending a request that exceeds ReadTimeout.
This foundation eliminates framework dependency while preserving production-grade reliability. As your service evolves, you can layer middleware, integrate telemetry, or swap routing strategies without restructuring the core server lifecycle. The standard library doesn’t limit you—it clarifies where your architecture actually begins.