Go's standard library has a production-capable HTTP server. Chi is a thin router on top of it. Gin is a faster framework with more opinions. All three are used in production at scale. The choice depends on what you value and what you're building — not benchmarks.
The three options
// stdlib: zero dependencies
mux := http.NewServeMux()
mux.HandleFunc("GET /invoices/{id}", handleGetInvoice)
// chi: routing + middleware, stdlib-compatible
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Get("/invoices/{id}", handleGetInvoice)
// Gin: framework with its own Context type
r := gin.New()
r.Use(gin.Logger())
r.GET("/invoices/:id", handleGetInvoice)stdlib: when it's enough
Go 1.22 added method and wildcard routing to http.ServeMux. For many APIs, it's now enough: GET /users/{id}, path parameters via r.PathValue("id"), and standard middleware patterns with function wrapping.
Choose stdlib when: zero dependencies matters (library, plugin, internal tool), you have simple routing needs, or you want your handlers to be trivially portable to any framework later.
chi: our usual choice
chi adds three things on top of stdlib: a cleaner route grouping API, composable middleware, and route context helpers. Critically, it uses the same http.Handler interface — any stdlib middleware works with chi without adaptation.
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Route("/api/v1", func(r chi.Router) {
r.Use(RequireAuth(cfg.JWTSecret))
r.Get("/invoices", h.ListInvoices)
r.Post("/invoices", h.CreateInvoice)
r.Get("/invoices/{id}", h.GetInvoice)
})Gin: when to use it
Gin has a larger ecosystem of Gin-specific middleware and a binding/validation layer built in. Its context type is different from stdlib, which means third-party middleware often comes in Gin-specific versions.
Choose Gin when: you want validation and binding out of the box, your team has Gin experience, or you're building a public API that needs swagger generation (gin-swagger is mature). Avoid it if you want to stay close to stdlib or minimize dependencies.
Server configuration that matters
srv := &http.Server{
Addr: cfg.Addr,
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20, // 1 MB
}Never use the default timeouts — they're zero, which means no timeout. A client that opens a connection and never sends a request will hold it open indefinitely. ReadTimeout is the time to read the entire request. WriteTimeout covers the response.
Graceful shutdown
Listen for SIGTERM and SIGINT, then call srv.Shutdown with a deadline. This lets in-flight requests complete before the process exits.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("server shutdown: %v", err)
}The middleware postcovers what to put on the router. If you're building a Go API from scratch, we can scope it with you.
End of article · Thanks for reading