← Back to the blog

HTTP servers in Go: stdlib vs chi vs Gin

Three solid options, different trade-offs. Here's the comparison we run in our head when starting a new Go backend — and the one we usually land on.

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.

Quick answerUse stdlib for internal services and CLIs with an HTTP endpoint. Use chi for most APIs — it adds routing without changing the handler signature. Use Gin if you want a batteries-included framework and are willing to learn its conventions.

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

Subscribe

More of this, once a month.