Skip to content

Go Web Frameworks in 2026: The One I Actually Ship

Go Web Frameworks in 2026: The One I Actually Ship

I spent the first three years of my Go life switching web frameworks every six months. Gin because a tutorial used it. Echo because a coworker swore by it. Fiber because someone at a meetup showed me a benchmark chart. Chi because I finally read the standard library docs and felt guilty about ignoring it.

Last year I got tired of the merry-go-round and picked one for real. This post is the honest write-up of that decision. It isn’t a benchmark chart, it isn’t a hot take. It’s what I actually type when a paying client hands me a project and asks how long it’ll take.

Short version for the impatient: I use Chi for anything I’ll still be paid to maintain in two years, Gin when I inherit a codebase that already has it, Fiber only for internal tools, and Echo almost never anymore. If you want the reasoning, read on.

What I actually optimize for

Before any framework talk, a confession. I care more about the shape of my handlers three years from now than I do about requests-per-second. If your service gets a thousand RPS with any of these frameworks, the framework isn’t what’s slowing you down. It’s the database call on line 47 of your handler, and no benchmark chart will save you from that.

So my priorities, in order:

  1. Standard http.Handler signature. If a framework wraps me in a custom context type, I lose the whole Go middleware ecosystem. Every OpenTelemetry integration, every rate limiter someone published on GitHub, every context-propagation pattern I already worked out.
  2. Small surface area. I want to open the framework’s source without needing a map.
  3. A middleware pattern I can explain to a junior engineer in five minutes.
  4. Router behavior I can predict without reading the tests.

Speed comes about seventh. On real workloads the gap between the “fastest” and “slowest” of these is buried under database latency and JSON encoding.

Chi: the boring one that keeps winning

I know. Chi isn’t exciting. Nobody’s writing think-pieces about it. The last time it got a big feature was a while ago. That’s the point.

Chi wraps net/http. Handlers are plain http.HandlerFunc. Middleware is func(http.Handler) http.Handler. Read those two sentences twice, because that’s basically the whole framework.

Here’s what a real route file looks like:

r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(5 * time.Second))

r.Route("/api/v1", func(r chi.Router) {
    r.Get("/users/{id}", getUser)
    r.Post("/users", createUser)
    r.With(auth).Delete("/users/{id}", deleteUser)
})

http.ListenAndServe(":8080", r)

getUser is a plain func(w http.ResponseWriter, r *http.Request). Not a chi.Context, not a chi.Handler. Just the standard signature. Which means every piece of Go middleware anyone has ever written on the internet works with Chi, no adapter required. That is the moat.

I’ve had Chi services running unattended for two years. Zero framework upgrades needed. The API just doesn’t move. That’s a feature, not a bug. You can browse the source on GitHub in an afternoon.

Gin: the default nobody regrets picking

Gin gets picked because it’s what the top Stack Overflow answer used. Fine. It isn’t a bad choice.

Its custom *gin.Context is the thing you have to make peace with:

func getUser(c *gin.Context) {
    id := c.Param("id")
    user, err := userService.Get(c.Request.Context(), id)
    if err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    c.JSON(200, user)
}

The c.JSON(200, user) is nice. Ergonomic. But now every helper I write takes *gin.Context, and if I ever want to move a handler to another framework I’m doing a search-and-replace tour. It also means I can’t just paste a random http.Handler middleware from GitHub. I have to use gin.WrapH() or find a Gin-specific version.

Where Gin shines: teams that ship a lot of small JSON endpoints and don’t care about middleware portability. If that’s you, Gin removes real friction. I inherit Gin codebases all the time and I don’t rip them out. But I don’t start new projects with it.

Fiber: fast until you need it to be something else

Fiber is built on fasthttp instead of net/http. That was a real technical decision. fasthttp avoids a lot of allocations and hits impressive numbers on benchmarks. If you’re building something where every microsecond matters (a network proxy, a high-throughput ingest pipeline), Fiber has a real argument.

Everywhere else, that decision costs you. fasthttp isn’t a drop-in replacement for net/http. HTTP/2 support is thinner. Some of the standard library’s HTTP tooling — httptest, http.ServeMux composition, a lot of context.Context idioms — either behaves differently or needs Fiber-specific replacements.

I keep Fiber around for internal dashboards and prototypes where the trade-offs don’t matter. The moment someone says “we need OpenTelemetry” or “we need to run behind a load balancer that expects net/http semantics”, I stop reaching for it.

Echo: the one I keep leaving behind

Echo is fine. It’s fast. The docs are solid. The middleware library is decent.

Here’s the thing. Everything Echo does well, Chi does with the standard signature and Gin does with slightly nicer syntax. Echo sits in this awkward middle where it has its own context type but doesn’t feel meaningfully more ergonomic than Gin, and it’s built on net/http but doesn’t have Chi’s “just standard handlers” story.

The last three Go projects I inherited, someone had started with Echo and then partway through the year had migrated key endpoints to Chi because a middleware they needed only existed for http.Handler. That’s a bad sign for a framework. I don’t start Echo projects anymore.

Standard net/http in 2026 (closer than you think)

This is the underrated one. Go 1.22 shipped routing enhancements that let you write:

mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)

http.ListenAndServe(":8080", mux)

Path parameters. Method-based routing. In the standard library. No framework.

For small services (an internal API, a sidecar, a webhook receiver, a health-check daemon), this is genuinely enough. I’ve shipped three services on plain net/http in the last year and I don’t miss anything.

Where it falls short: no built-in middleware chaining (you write middleware1(middleware2(handler)) by hand, which gets old at scale), no sub-router pattern that’s as clean as Chi’s r.Route(), and no baked-in helpers for WriteJSON. You’ll write those yourself, or you’ll graduate to Chi.

If your service has more than about fifteen routes or nested route groups, you’ll want Chi. Below that? The standard library is the boring right answer.

The middleware thing that decides it for me

I write a lot of middleware. Rate limiters. Request-ID injectors. Structured logging. Auth. Some of it is fifteen lines. Some is fifty. All of it looks like this:

func withRequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }
        ctx := context.WithValue(r.Context(), reqIDKey, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

That code runs unchanged on net/http, on Chi, and on any framework that respects the standard http.Handler interface. It doesn’t run on Gin without a wrapper. It doesn’t run on Fiber at all.

When I picked Chi as my default, this was the reason. Not benchmarks. Not features. I want the middleware I write in 2026 to still work in 2029 without a rewrite. If you’re wiring up errgroup and context.Context across your handlers, I wrote up the concurrency patterns I actually ship in my post on Go concurrency, and every one of them assumes the standard handler signature.

How I’d pick if I were starting today

If I were making the call for a new project this week, I’d ask three questions:

  1. Will this codebase outlive me on the project? If yes → Chi.
  2. Do I have fewer than about fifteen routes? If yes → standard net/http.
  3. Do I care about raw request throughput more than ecosystem compatibility? If yes → Fiber, and I’d know exactly what I was signing up for.

Gin only becomes the answer if the team already has Gin muscle memory and I don’t want to fight it. Echo I’d only pick if a client specifically demanded it.

If you’re bouncing between three frameworks on personal projects, do yourself a favor and build the same small service (a link shortener, or a wrap-this-API-and-add-caching proxy) in Chi and in standard net/http. You’ll form an actual opinion instead of a curated one from a benchmark chart, and next time someone in Slack asks “which framework?” you can answer without waffling. That’s the exercise I did last year, and it’s the kind of Go backend work I now put in my portfolio. Chi won for me. It might not win for you. But the exercise itself is the thing.