net/http vs Gin vs Chi

When You Don't Need a Framework

Web
Use net/http for zero-dependency services, chi for lightweight routing, and avoid heavy frameworks like Gin unless specific features are required.

The standard library is not a skeleton

You're building a microservice. You need a few endpoints. You open your editor and pause. Do you import net/http? Do you pull in Gin? What about Chi? The Go ecosystem feels like a buffet where half the dishes are just the same pasta with different sauces. You want something that works, scales, and doesn't add a maintenance nightmare. The answer usually starts with the standard library.

Go's net/http package is not a minimal starter kit. It is a complete, production-ready HTTP server. It handles connections, parses headers, manages TLS, and routes requests. When you see "framework" in Go, you're usually looking at a thin layer that adds routing syntax, middleware chaining, or JSON binding on top of net/http. You never replace net/http. You only wrap it. This matters because your code still runs on the same underlying server. The trade-off is always convenience versus cognitive load and dependency management.

The handler interface is the contract

The key to understanding Go HTTP servers is the http.Handler interface. It has one method: ServeHTTP(http.ResponseWriter, *http.Request). Every router, every middleware, and every framework ultimately implements this interface. When you call http.ListenAndServe, you pass an http.Handler. The server calls ServeHTTP for every request. This interface is the contract. Libraries that respect this contract integrate seamlessly. Libraries that hide it create friction.

Here's the simplest server you can write. No dependencies, no build cache misses, just Go.

package main

import (
	"fmt"
	"net/http"
)

// main starts the HTTP server on port 8080.
func main() {
	// HandleFunc registers a handler for the root path.
	// The handler function receives the response writer and the request.
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// WriteHeader is implicit 200 if you write body first, but explicit is safer.
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, "Hello from stdlib")
	})

	// ListenAndServe starts the server. It blocks until the process exits.
	// Passing nil uses the DefaultServeMux, which HandleFunc registers to.
	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

When you run this, http.ListenAndServe creates a TCP listener on port 8080. It spawns a goroutine for each incoming connection. The DefaultServeMux matches the URL path against your registered patterns. If a match is found, your handler runs. If not, the server returns a 404. The standard library handles the connection lifecycle, header parsing, and response flushing. You get a working server with zero external packages. This is the baseline. Everything else builds on this behavior.

Middleware is just composition

Middleware is a function that takes an http.Handler and returns a new http.Handler. The wrapper adds behavior before or after calling the inner handler. You can compose middleware by nesting them. The standard library supports this pattern natively. You don't need a framework to write middleware. You need a function that returns a handler.

Here's a logging middleware written with the standard library. It wraps the next handler and prints the method and path. The http.HandlerFunc adapter lets you use a function as a handler. This pattern is composable. You can chain multiple middlewares by passing the result of one into the next. The standard library gives you the tools. You just have to use them.

package main

import (
	"log"
	"net/http"
)

// loggingMiddleware wraps a handler to log requests.
// It returns a new handler that implements http.Handler.
func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Log the method and path before calling the next handler.
		log.Printf("%s %s", r.Method, r.URL.Path)
		// Call the next handler in the chain.
		next.ServeHTTP(w, r)
	})
}

// main demonstrates middleware composition.
func main() {
	// Create a base handler.
	base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("done"))
	})

	// Wrap the base handler with logging.
	// The result is a new handler that logs then calls the base.
	handler := loggingMiddleware(base)

	// ListenAndServe accepts the composed handler.
	http.ListenAndServe(":8080", handler)
}

Convention aside: gofmt is mandatory. Don't argue about indentation; let the tool decide. Most editors run it on save. Frameworks sometimes encourage formatting styles that conflict with gofmt. Stick to the tool. It keeps the codebase consistent.

When the standard mux hurts

Real services need more than one endpoint. They need grouping, path parameters, and middleware helpers. net/http supports this, but the syntax gets verbose. You end up writing helper functions to wrap handlers and match paths. This is where libraries like chi shine. chi is a router that stays close to the standard library. It uses the same http.Handler interface, adds a clean URL router, and provides middleware helpers. It doesn't reinvent the wheel. It just makes the wheel easier to steer.

Here's a service using Chi. Notice the handler function looks identical to the standard library version. Chi adds routing and middleware without changing the core contract.

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
)

// main sets up a Chi router with middleware and routes.
func main() {
	// NewMux creates a router that implements http.Handler.
	r := chi.NewMux()

	// Use adds middleware to the router.
	// Logger prints request details to stdout.
	r.Use(middleware.Logger)

	// Recoverer catches panics and returns a 500 error.
	// This prevents a single panic from crashing the server.
	r.Use(middleware.Recoverer)

	// Group routes under a common prefix.
	r.Group(func(r chi.Router) {
		// Get registers a handler for GET requests.
		// The handler signature matches the standard library.
		r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
			// Context is passed through the request.
			// Middleware can inject values or deadlines.
			ctx := r.Context()

			// Timeout demonstrates context cancellation.
			// If the context is cancelled, the handler should stop.
			select {
			case <-ctx.Done():
				fmt.Fprint(w, "cancelled")
				return
			case <-time.After(10 * time.Millisecond):
				fmt.Fprint(w, "ok")
			}
		})
	})

	// ListenAndServe accepts the router as the handler.
	if err := http.ListenAndServe(":8080", r); err != nil {
		panic(err)
	}
}

Convention aside: context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. If you use a framework that hides context, you lose this guarantee. Always extract the context early and pass it through your call chain. The worst goroutine bug is the one that never logs. If a handler spawns a background goroutine, it must use the request context to cancel work when the client disconnects. Otherwise, you leak goroutines.

Framework friction and dependency cost

Frameworks introduce friction. Gin, for example, uses a custom context type. You can't pass a Gin context to a function that expects context.Context without extraction. If you mix frameworks and standard library code, you get type mismatches. The compiler rejects this with cannot use c (variable of type *gin.Context) as context.Context value in argument. You have to call c.Request.Context() to get back to the standard type. This extraction adds boilerplate and breaks the flow.

Dependency bloat is another cost. go mod tidy pulls in transitive dependencies. If a framework updates, your build might break. The standard library updates with the Go version. You control the upgrade cycle. When you import a framework, you inherit its dependency graph. You also inherit its bugs. The standard library is tested against the compiler. Frameworks are tested by their maintainers and users. The risk profile is different.

Testing is easier with the standard library. httptest.NewRecorder lets you capture responses without starting a server. You can pass a recorder and a request to ServeHTTP. Frameworks often require mocking their internal context or router state. This adds complexity to tests. The standard library interface is simple to mock. You test the handler, not the framework.

Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. In HTTP handlers, you often see if err != nil { http.Error(w, err.Error(), 500); return }. This is explicit. Frameworks might hide errors in a context or a logger. Explicit is better for debugging.

Accept interfaces, return structs

The Go community mantra "accept interfaces, return structs" applies to routers. Routers should return http.Handler. Chi returns chi.Router, which implements http.Handler. This is good. It means you can pass a Chi router to any function expecting a handler. Gin returns *gin.Engine, which also implements http.Handler. Both follow this rule. The difference is in the handler signature. Chi handlers are func(http.ResponseWriter, *http.Request). Gin handlers are func(*gin.Context). The Gin signature changes the contract. This is the trade-off.

If you write a function that takes a Gin handler, you lock callers into Gin. If you write a function that takes an http.Handler, anyone can pass a standard library handler, a Chi router, or a Gin engine. The interface is the boundary. Keep your boundaries wide.

Decision matrix

Use net/http when you want zero dependencies and the standard library covers your routing needs. Use chi when you need path parameters, route grouping, and middleware helpers while keeping http.Handler compatibility. Use gin when you need automatic JSON binding, validation, and a fluent API for rapid development. Use a framework when your team's productivity depends on shared conventions and the framework enforces them.

The standard library is not a constraint. It is a foundation. Don't import a router until the standard mux hurts. Framework convenience costs context extraction.

Where to go next