I spent most of last Thursday pulling Gin out of a service that had eleven routes. Eleven. The framework was doing maybe four percent of the work and soaking up about forty percent of the onboarding cost, because every new person on the project had to learn Gin’s context object before they could learn ours.
That’s not a Gin problem. That’s me picking a framework in 2021 out of habit and never going back to check whether the reason still held.
Short version for the impatient: for most Go services I now start with net/http and the standard library router, reach for chi when I want middleware composition without ceremony, and only pick Gin or Echo when the team already ships one of them happily. If you want the reasoning, keep reading. There’s code.
What actually changed in the standard library
Go 1.22 added method matching and wildcard path segments to http.ServeMux. That sounds like a small quality of life thing. It quietly removed the single biggest reason people install a router.
Here’s what a route used to look like, with Gin:
r := gin.Default()
r.GET("/posts/:id", func(c *gin.Context) {
id := c.Param("id")
c.JSON(200, gin.H{"id": id})
})
r.Run(":8080")
And here’s the same route on the standard library today:
mux := http.NewServeMux()
mux.HandleFunc("GET /posts/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id": id})
})
http.ListenAndServe(":8080", mux)
Four extra lines. In exchange you drop a dependency, your handlers are plain http.Handler, and anyone who has read the standard library docs can read your code without a detour. The Go team wrote up the design reasoning in Routing Enhancements for Go 1.22. Read the part about pattern precedence carefully, because the rule for which pattern wins when two of them match is the one thing in there that will bite you.
I was wrong about this for roughly a year. I assumed the new mux was a toy version and kept installing routers on muscle memory.
Where the standard library still hurts
Two places, and they’re both real.
The first is middleware. net/http has no opinion about it, so you either write your own chaining helper or you accept a nest:
handler := logging(auth(rateLimit(mux)))
Read that out loud. Now picture it at six layers and try to remember which one runs first. I’ve written the little chain(...) helper that fixes this maybe five times in five codebases, slightly differently each time, which is a good sign I should stop writing it.
The second is route grouping. If /api/v1/* needs auth and /public/* doesn’t, you’re building that by hand with sub-muxes and http.StripPrefix. It works. It’s tedious, and it gets less obvious as the tree grows.
Both of those are what chi exists for, and chi handlers are still ordinary http.Handler:
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Route("/api/v1", func(r chi.Router) {
r.Use(authRequired)
r.Get("/posts/{id}", getPost)
r.Post("/posts", createPost)
})
That’s the sweet spot for me. Grouping and middleware, and no custom context type leaking into every function signature in the repo.
The benchmark argument is mostly noise
Every framework comparison opens with a table of requests per second. Gin publishes its own numbers, they’re honest, and they measure routing.
Routing is not your bottleneck. I have never profiled a Go service and found the router near the top. It’s the database, then serialization, then some HTTP call to a service another team owns. Router differences land in nanoseconds while your Postgres query lands in milliseconds. That gap is big enough that optimizing the first one is a rounding error on the second.
Fiber is the interesting exception, because there the benchmark is measuring something structural. It’s built on fasthttp rather than net/http, which is where the speed comes from and also where the cost sits: your handlers aren’t http.Handler, so a good chunk of the ecosystem’s middleware, tracing, and test tooling doesn’t plug in without an adapter. If you’re serving enough traffic that the difference shows on the bill, that’s a trade worth making. If you’re not, you paid in compatibility for a number you’ll never see.
The part that convinced me: testing
The argument that actually moved me wasn’t performance or dependencies. It was what happens when you write tests.
If your handler is an http.Handler, testing it needs nothing beyond net/http/httptest, which ships with Go:
func TestGetPost(t *testing.T) {
req := httptest.NewRequest("GET", "/posts/42", nil)
req.SetPathValue("id", "42")
w := httptest.NewRecorder()
getPost(w, req)
if w.Code != http.StatusOK {
t.Fatalf("got %d, want 200", w.Code)
}
}
No framework import in the test file. No constructing a fake *gin.Context and hoping you populated the right fields. With Gin the equivalent test needs gin.CreateTestContext, and now your test file knows about your web framework, which means changing the framework means touching every test.
The same logic applies to middleware. Anything written as func(http.Handler) http.Handler composes with anything else written that way, including packages you didn’t write. OpenTelemetry’s HTTP instrumentation, gorilla/handlers, CORS packages, and most of the observability tooling all speak that interface, because it’s the one the standard library defined. Frameworks with custom handler types either wrap all of that or ask you to use their version of it.
This is the quiet compounding cost. It doesn’t show up on day one. It shows up on the day you want to add distributed tracing and discover the adapter is unmaintained.
The dependency cost nobody puts in the table
A router isn’t a one time decision, it’s a subscription. You’re signing up for its release cadence, its security advisories, its opinion about how errors get rendered, and its breaking changes at major versions. On a service that lives five years, that adds up to more of your attention than the initial integration ever did.
The standard library version of that subscription is the Go release cycle, which you’re already on.
This is also why I’ve stopped treating “which framework is best” as the interesting question. The interesting question is how much of the framework you’re actually using. On the eleven route service, the answer was c.JSON and route params. Two features. I was carrying a whole dependency and a bespoke context type for two features.
How I choose now
Three questions, in order, stopping at the first yes.
Does the team already ship Gin or Echo without complaining? Keep it. Framework churn costs more than framework choice, and rewriting a working router is the most satisfying way I know to spend a week producing nothing. This is the answer most of the time and nobody likes hearing it.
Is this a service with under roughly twenty routes and simple middleware? Standard library.
Otherwise chi, and stop there.
Echo is worth a look if you want binding and validation included rather than assembled, which is a genuine advantage over Gin. JetBrains put together a reasonable survey of the current Go web frameworks if you want the field laid out side by side before you commit.
The thing that never shows up in these comparisons: whichever one you pick, you’ll spend far more time on context cancellation and error handling than on routing. I wrote about the context mistakes I was making for two years, and that’s still where the actual production bugs come from. Same story with Go error handling patterns. The router is the part of your service least likely to page you at 3am.
Try this on one service this week
Pick a service. Count the routes.
Under twenty and you’re on Gin or Echo? Port only the router to http.ServeMux on a throwaway branch. Don’t touch anything else. On my eleven route service this took under an hour and the diff deleted more lines than it added.
Over twenty, or the middleware nesting is already ugly? Port to chi instead and stop there. You get the grouping without the custom context.
Either way you’ll find out how much of your framework you were actually using, which is the number that should have driven the decision in the first place. I keep the before and after diffs from migrations like this in my work if you want to see what it looks like on a real codebase rather than a toy one.
Worst case you throw the branch away and now you have a reason for the framework instead of a habit.