Go’s net/http is frequently underrated. The ecosystem has frameworks — Chi, Gin, Echo, Fiber — and they’re fine choices, but the standard library gets you remarkably far without additional dependencies. After building several production APIs that stayed on raw net/http, here’s the honest assessment of what you can and can’t do without a framework, and the patterns that make it work.
What net/http Gives You
Out of the box:
- HTTP/1.1 and HTTP/2 (TLS required for H2, handled automatically)
- Request multiplexing via
ServeMux - Goroutine per connection (managed internally)
- Timeout configuration at the server level
- TLS via
crypto/tls
What it notably lacks:
- Path parameters (
/users/{id}) - Named routes
- Route grouping with shared middleware
- Request body size limiting (requires manual wrapping)
The path parameter gap is the main reason teams reach for a router library. More on how to handle it below.
The Baseline Server
A production-ready server with timeouts:
| |
Never run a production server without timeouts. A client that opens a connection and never sends headers will hold the goroutine forever with zero timeouts. ReadHeaderTimeout prevents this. WriteTimeout prevents slow-read clients from holding the goroutine during response streaming.
IdleTimeout controls how long HTTP/1.1 keep-alive connections stay open when idle. Too long and you accumulate file descriptors; too short and clients pay more TCP handshake overhead. 60–120 seconds is typical.
Middleware Chain
The standard middleware pattern: a function that takes an http.Handler and returns an http.Handler.
| |
Standard middleware you’ll write once and reuse:
| |
Path Parameters Without a Framework
http.ServeMux in Go 1.22+ supports path parameters and method routing natively:
| |
Before 1.22, the standard approach was to use strings.TrimPrefix and manual parsing for simple cases, or add a lightweight router (chi is 600 lines of code with no dependencies, httprouter is similarly minimal). For Go 1.22+, the stdlib mux handles most routing needs.
Graceful Shutdown
A server that drops in-flight requests when the process exits is a production bug. Graceful shutdown waits for in-flight requests to complete:
| |
srv.Shutdown(ctx) stops accepting new connections and waits for active handlers to return. If the context deadline is exceeded before all handlers finish, it returns an error (handlers are still forcibly closed by the context cancellation). 30 seconds is a reasonable timeout for most APIs; adjust for handlers that may do long work.
Observability: What to Instrument
Minimum viable instrumentation for an HTTP server:
| Metric | Type | Labels |
|---|---|---|
http_requests_total | Counter | method, path (template), status_code |
http_request_duration_seconds | Histogram | method, path (template), status_code |
http_requests_in_flight | Gauge | — |
http_response_size_bytes | Histogram | method, path |
The key: use the route template (/trades/{id}) as the label, not the actual path (/trades/12345). Using the actual path creates unbounded label cardinality — a separate time series per trade ID is a metrics cardinality explosion.
| |
When to Add a Framework
After years of this pattern, I add a framework when:
- The routing logic is genuinely complex — many path parameters, complex nesting, route groups with different middleware trees. Chi or Gorilla Mux handles this more cleanly than manual mux composition.
- The team is unfamiliar with HTTP primitives — a framework provides guardrails and familiar patterns.
net/httprewards understanding; it punishes wrong assumptions about streaming, buffering, and header writing. - OpenAPI / Swagger generation is needed — frameworks often have codegen tooling that reduces boilerplate.
The case against framework defaults: most frameworks add dependencies, each with their own maintenance trajectory. A service built on raw net/http has one less dependency to worry about when the next framework major version breaks backwards compatibility. For internal services with stable teams, the stdlib is the stable choice.