Go Web Frameworks in 2026: net/http, chi, Gin, Echo, Fiber Compared
Five Go HTTP stacks, one benchmark rig, real numbers.
Go ships a production-capable HTTP server in its standard library, and since Go 1.22 the built-in router handles method matching and path parameters on its own. The framework question has narrowed, not disappeared.
Five stacks cover almost every Go backend decision in 2026: the standard library’s net/http, chi, Gin, Echo, and Fiber. Each takes a different position on the same tradeoff — what the library gives you versus what you owe it in dependencies, conventions, and ecosystem compatibility. This article compares them on routing power, handler ergonomics, middleware, and measured performance, then ends with a decision table rather than a single verdict.
If you are starting from zero and want the full build process around your choice — validation, authentication, testing, deployment — the step-by-step version lives in Building REST APIs in Go. Here the focus stays on the stacks themselves.

The post-1.22 ServeMux reset
The reason the framework debate changed shape is one addition to the standard library. Go 1.22 gave http.ServeMux method-aware patterns and path parameters:
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// ...
})
mux.HandleFunc("POST /users", createUser)
Before this, any API needing GET /users/{id} required a third-party router, which is how Gin, chi, and Echo became defaults. In 2026, the stdlib covers static routes, method matching, single-segment and wildcard parameters, and middleware via plain function wrapping. What it still does not give you: route groups, composable middleware chains, struct binding, or per-route param types.
The contenders
| net/http (stdlib) | chi v5.3.2 | Gin v1.12.0 | Echo v4.16.0 | Fiber v2.52.15 | |
|---|---|---|---|---|---|
| HTTP engine | net/http | net/http | net/http | net/http | fasthttp |
| Handler signature | func(w, r) |
func(w, r) |
func(c *gin.Context) |
func(c echo.Context) error |
func(c *fiber.Ctx) error |
| http.Handler compatible | yes | yes | yes | yes | no |
| Middleware ecosystem | growing, stdlib-shaped | stdlib-shaped | largest, Gin-specific | large, Echo-specific | growing, fasthttp-specific |
| Binding/validation | manual | manual | built-in tags | built-in | via helpers |
| HTTP/2 | yes | yes | yes | yes | no |
| Dependencies | 0 | 1 | ~6 | ~8 | ~4 |
All five use trie- or radix-style routers with path parameters and grouped routes. The structural divide is the last row of the handler-signature story: chi and stdlib speak plain http.Handler; Gin, Echo, and Fiber wrap requests in their own context type. Fiber is the outlier once more because it sits on fasthttp, not net/http at all.
What I benchmarked
Rather than quoting synthetic router benchmarks, I ran the same service on all five stacks. The rig:
- Go 1.27.1, Gin v1.12.0, chi v5.3.2, Echo v4.16.0, Fiber v2.52.15
- Intel Core Ultra 7 265, Linux, 20 threads, localhost, no TLS
- Three routes per stack:
GET /health,GET /users/{id},GET /api/v1/users/{uid}/orders/{oid} - Load: 8 keep-alive workers for 3 seconds against the two-parameter route, after a warmup pass
- Every handler marshals a small JSON document per request with
encoding/json— so the numbers include serialization, not just routing
The stdlib version of the hot route:
mux.HandleFunc("GET /api/v1/users/{uid}/orders/{oid}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(orderBody(r.PathValue("uid"), r.PathValue("oid")))
})
and the Fiber equivalent:
app.Get("/api/v1/users/:uid/orders/:oid", func(c *fiber.Ctx) error {
c.Set("Content-Type", "application/json")
return c.Send(orderBody(c.Params("uid"), c.Params("oid")))
})
Two honesty notes before the numbers. First, this is a localhost micro-workload: no database, no TLS handshake, no middleware chain. Real services bottleneck on I/O — the framework’s share of a request that queries PostgreSQL via GORM, Ent, Bun, or sqlc is a rounding error. Second, each stack got its own server process and an identical handler shape, so the comparison is between stacks, not between hand-tuned configurations.
Results
Throughput on the dynamic two-parameter route:
| Stack | Requests/sec | Avg latency |
|---|---|---|
| Fiber (fasthttp) | 205,958 | 0.034 ms |
| Echo | 169,826 | 0.042 ms |
| Gin | 169,715 | 0.042 ms |
| net/http ServeMux | 169,708 | 0.042 ms |
| chi | 163,017 | 0.044 ms |
The four net/http-based stacks land within 4% of each other. Fiber’s fasthttp engine is about 21% ahead on this localhost workload — real, but the kind of gap that a single database roundtrip (0.5–5 ms) makes irrelevant in production.
To see where the per-request cost actually goes, I also benchmarked handler invocation without the network, calling each router directly through http.Handler.ServeHTTP (Fiber through its fasthttp handler):
| Stack | Time/op | Bytes/op | Allocs/op |
|---|---|---|---|
| Gin | 289 ns | 192 B | 3 |
| Echo | 333 ns | 192 B | 3 |
| net/http ServeMux | 430 ns | 240 B | 5 |
| Fiber | 541 ns* | 192 B | 3 |
| chi | 592 ns | 897 B | 7 |
* The Fiber figure carries harness overhead — the test has to copy the request into fasthttp’s reusable context each iteration, which the real server avoids by reading from the wire. Treat it as an upper bound; the server-level throughput above is the fair comparison for Fiber.
The allocation profile is the more durable story. Gin and Echo pool their request contexts, so a request through them costs 3 allocations. The stdlib’s 5 allocations go to the request-scoped plumbing itself. chi stores route parameters in the request context.Context, which shows up as 897 B and 7 allocations per request — the price of staying a plain http.Handler with rich route info.
Fiber and the fasthttp tradeoff
Fiber is the fastest stack on the server benchmark, and the speed comes with a compatibility bill. fasthttp is a from-scratch HTTP implementation with its own request/response types, so:
net/httpmiddleware does not attach. Any library expecting anhttp.Handleror*http.Requestneeds a conversion or a Fiber-native port.- No HTTP/2. fasthttp does not implement it; Go’s
net/httpgets h2 from the standard library for free over TLS. - WebSocket, streaming, and
http.Flusher-style tricks all take the fasthttp path, which differs from the stdlib behavior most Go documentation assumes.
What Fiber buys in exchange: the fastest numbers in the table above, an Express-like API, and lower allocations than the framework benches suggest once the wire parsing advantage is included. It fits when responses are small, routing dominates, HTTP/2 is terminated upstream, and the middleware you need exists in the Fiber ecosystem. Those conditions hold for some edge services and hold for almost nothing else.
chi: the boring middle path
chi is a router and a middleware pipeline, nothing more. Handlers are func(w, r), middleware is func(http.Handler) http.Handler, and everything written for the stdlib attaches without adapters:
r := chi.NewRouter()
r.Use(RequestID, RealIP, Logger, Recoverer)
r.Route("/api/v1", func(r chi.Router) {
r.Get("/users/{uid}/orders/{oid}", getOrder)
})
That is the whole pitch: route groups and a middleware stack on top of the standard interface. The allocation table prices it honestly — richer route context per request than the stdlib alone, in exchange for composable middleware and nested routes. For services that want structure without a framework identity, chi plus net/http is the configuration most Go teams converge on.
Gin vs Echo, in code
Gin and Echo are near-twins on performance — within noise in both benchmarks — so the choice between them is API taste and error-handling philosophy.
Echo centralizes errors in an HTTPErrorHandler. Handlers return error, and one function maps errors to responses:
func getOrder(c echo.Context) error {
o, err := store.Order(c.Param("uid"), c.Param("oid"))
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "order not found")
}
return c.JSON(http.StatusOK, o)
}
Gin handlers return nothing; errors accumulate on the context and you render them where you choose:
func getOrder(c *gin.Context) {
o, err := store.Order(c.Param("uid"), c.Param("oid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "order not found"})
return
}
c.JSON(http.StatusOK, o)
}
Echo’s model maps cleanly onto Go error handling architecture with wrapped errors and centralized boundary handling — one place translates errors.Is results into status codes. Gin’s model matches the more common pattern of deciding per handler. Beyond errors, Gin’s deciding advantages are ecosystem size and built-in struct binding with validation tags; Echo’s are a consistent context API and stronger built-in middleware. Both integrate Swagger generation through their own adapters — the per-framework setup is covered in Adding Swagger to Your Go API.
One more axis: context propagation. Gin and Echo wrap context.Context inside their own context types, so passing cancellation into handlers requires an extraction step. The patterns for keeping cancellation, timeouts, and values straight across any of these wrappers are in Go context.Context Done Right.
The timeouts nobody sets
Every stack in this comparison leaves server timeouts at zero defaults unless you set them — and zero means no limit. A client that opens a connection and sends nothing holds a goroutine and a file descriptor indefinitely:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
srv.ListenAndServe()
ReadHeaderTimeout is the one to set first — it bounds slowloris-style connection exhaustion without breaking long uploads the way a blanket ReadTimeout can. WriteTimeout must cover the slowest legitimate response, which makes it a policy decision tied to your timeout budgets rather than a copy-paste constant. The same discipline applies per framework: Gin, Echo, and Fiber all accept server-level timeout configuration, and none of them enable it by default.
Choosing
or zero-dependency requirement?} B -- yes --> C[net/http ServeMux] B -- no --> D{Want built-in binding,
validation, big middleware catalog?} D -- no --> E[chi + net/http] D -- yes --> F{Team convention?} F -- error-returning handlers --> G[Echo] F -- largest ecosystem, Gin idioms --> H[Gin] A --> I{Small responses, extreme throughput,
no HTTP/2, upstream TLS termination?} I -- all true --> J[Fiber] I -- no --> E
| Situation | Pick |
|---|---|
| Internal service, CLI endpoint, plugin, library | net/http alone |
| Most APIs — structure without framework lock-in | chi on top of net/http |
| Public API, larger team, validation and binding out of the box | Gin |
| Public API, centralized error translation, consistent context API | Echo |
| Proven routing/serialization bottleneck, tiny JSON, no HTTP/2 | Fiber |
If the honest default for a new service in 2026 has to be named: start with net/http, add chi when middleware or route groups start to ache, and move to Gin or Echo only when binding, validation, or team convention justifies a framework identity. Fiber stays a specialist tool — worth benchmarking against when throughput is proven to be the problem, priced against the compatibility bill it charges.
Migrating between stacks
Migration cost tracks the handler-signature divide. stdlib ↔ chi is mechanical: handlers keep the same signature, routes move from mux.HandleFunc patterns to r.Get, middleware attaches to the chi stack. Gin ↔ Echo is a context-type translation — c.Param, c.JSON, and middleware logic port nearly line-for-line. Anything moving to or from Fiber is a rewrite of the HTTP layer, because *http.Request-based code, net/http middleware, and h2 assumptions do not survive the fasthttp boundary; the least painful route is keeping business logic in framework-free packages and regenerating the transport layer, which is also the structure that makes the next migration cheap.