Skip to content
gohacks.dev
Performance

Benchmark Routers for Go Web Servers and Read Them Fairly

Benchmark net/http ServeMux, chi, httprouter and gin on 50 routes: gin matched a param route in 37 ns, ServeMux in 120 ns, and why that rarely matters

8 min read
Summarize in AI
Flat illustration of a rising bar chart and a gauge.

Router benchmarks are among the most quoted and least useful numbers in the Go ecosystem. They are quoted because the differences are large in relative terms (chi against gin is eight times on one row below), and they are useless when taken alone because the absolute time is hundreds of nanoseconds in a program whose handlers take milliseconds. This article measures four routers fairly, with the same 50 routes, the same requests and a response writer that discards everything, then works out what the numbers mean for a real service, and what they do not capture.

Tested with Go 1.25.6, chi v5.2.1, httprouter v1.3.0, gin v1.10.1, Apple M1 Max (10 cores, 32 GB), macOS arm64, 50 routes, median of 5 runs of 150 ms

It uses the method from benchmarking a Go function properly. If you are deciding between them for a project, read chi against gin alongside this, and for the cost of what runs after routing, see middleware. The cornerstone is the Go performance guide.

The setup

Ten resources (users, orders, products and seven more) each get five routes: a list (GET), a create (POST), a read by ID (GET with one parameter), an update (PUT with one parameter) and a nested route with two parameters (/orders/{id}/comments/{cid}). That is 50 routes, registered in all four routers with their own syntax: {id} in net/http 1.22 patterns and chi, :id in httprouter and gin. Handlers do nothing. Each request goes through ServeHTTP with a nopWriter whose methods discard the output, so the measurement is the router and not the network or the recorder.

main.goGo
package main

import (
	"flag"
	"fmt"
	"net/http"
	"sort"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/go-chi/chi/v5"
	"github.com/julienschmidt/httprouter"
)

func init() {
	testing.Init()
	flag.Set("test.benchtime", "150ms")
	gin.SetMode(gin.ReleaseMode)
}

// nopWriter discards the response so the router is what is measured.
type nopWriter struct{ h http.Header }

func (w *nopWriter) Header() http.Header         { return w.h }
func (w *nopWriter) Write(b []byte) (int, error) { return len(b), nil }
func (w *nopWriter) WriteHeader(int)             {}

var resources = []string{"users", "orders", "products", "invoices", "carts", "reviews", "coupons", "tickets", "teams", "projects"}

// 10 resources x 5 routes = 50 routes, plus three single routes.
type route struct{ method, std, chi, httprouter, gin string }

func routes() []route {
	var rs []route
	for _, r := range resources {
		rs = append(rs,
			route{"GET", "GET /" + r, "/" + r, "/" + r, "/" + r},
			route{"POST", "POST /" + r, "/" + r, "/" + r, "/" + r},
			route{"GET", "GET /" + r + "/{id}", "/" + r + "/{id}", "/" + r + "/:id", "/" + r + "/:id"},
			route{"PUT", "PUT /" + r + "/{id}", "/" + r + "/{id}", "/" + r + "/:id", "/" + r + "/:id"},
			route{"GET", "GET /" + r + "/{id}/comments/{cid}", "/" + r + "/{id}/comments/{cid}", "/" + r + "/:id/comments/:cid", "/" + r + "/:id/comments/:cid"},
		)
	}
	return rs
}

func median(f func(b *testing.B)) (float64, int64) {
	const runs = 5
	xs := make([]float64, 0, runs)
	var r testing.BenchmarkResult
	for i := 0; i < runs; i++ {
		r = testing.Benchmark(f)
		xs = append(xs, float64(r.T.Nanoseconds())/float64(r.N))
	}
	sort.Float64s(xs)
	return xs[runs/2], r.AllocsPerOp()
}

func main() {
	h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
	rs := routes()

	std := http.NewServeMux()
	cr := chi.NewRouter()
	hr := httprouter.New()
	g := gin.New()
	for _, r := range rs {
		std.HandleFunc(r.std, h)
		cr.Method(r.method, r.chi, h)
		hr.Handler(r.method, r.httprouter, h)
		g.Handle(r.method, r.gin, func(c *gin.Context) {})
	}

	routers := []struct {
		name string
		h    http.Handler
	}{{"net/http ServeMux", std}, {"chi", cr}, {"httprouter", hr}, {"gin", g}}

	cases := []struct{ name, path string }{
		{"static /users", "/users"},
		{"one param /orders/42", "/orders/42"},
		{"two params /projects/7/comments/99", "/projects/7/comments/99"},
		{"last registered /projects/7", "/projects/7"},
		{"not found /nothing/here", "/nothing/here"},
	}
	fmt.Printf("%d routes registered; ns per request through ServeHTTP (and allocations)\n\n", len(rs))
	fmt.Printf("%-36s", "request")
	for _, r := range routers {
		fmt.Printf(" %18s", r.name)
	}
	fmt.Println()
	for _, c := range cases {
		fmt.Printf("%-36s", c.name)
		for _, r := range routers {
			req, _ := http.NewRequest("GET", c.path, nil)
			w := &nopWriter{h: http.Header{}}
			ns, al := median(func(b *testing.B) {
				b.ReportAllocs()
				for b.Loop() {
					r.h.ServeHTTP(w, req)
				}
			})
			fmt.Printf(" %11.0f ns /%2d", ns, al)
		}
		fmt.Println()
	}
}
Text
50 routes registered; ns per request through ServeHTTP (and allocations)

request                               net/http ServeMux                chi         httprouter                gin
static /users                                 72 ns / 0         170 ns / 2          18 ns / 0          29 ns / 0
one param /orders/42                         120 ns / 1         307 ns / 4         185 ns / 4          37 ns / 0
two params /projects/7/comments/99           232 ns / 2         355 ns / 4         193 ns / 4          50 ns / 0
last registered /projects/7                  123 ns / 1         316 ns / 4         189 ns / 4          42 ns / 0
not found /nothing/here                      767 ns /16         308 ns / 5         308 ns / 3          40 ns / 0

Results

Nanoseconds per request and allocations per request, 50 routes registered:

Requestnet/http ServeMuxchihttproutergin
static /users72 ns / 0170 ns / 218 ns / 029 ns / 0
one param /orders/42120 ns / 1307 ns / 4185 ns / 437 ns / 0
two params /projects/7/comments/99232 ns / 2355 ns / 4193 ns / 450 ns / 0
last registered /projects/7123 ns / 1316 ns / 4189 ns / 442 ns / 0
not found /nothing/here767 ns / 16308 ns / 5308 ns / 340 ns / 0

What explains the pattern

Radix trees are fast, and what a router does around the tree decides the rest. httprouter and gin share the same design (a radix tree per method, no regular expressions, parameters returned as a slice). That is why a static lookup takes 18 and 29 ns. httprouter's 185 ns for a parameter route is not the tree. The lab registered it through Handler, which adapts a plain http.Handler, and that adapter copies the parameters into the request context: a context allocation and a request copy, four allocations in all. With its native httprouter.Handle signature it would be close to gin's number; the number here is the cost of using it through the standard interface, which is how most code would.

gin has no allocation because its context is pooled. gin hands each request a *gin.Context taken from a sync.Pool, and parameters live in the context. The price is that handlers get gin's context and not the standard http.Request, which is a coupling to the framework that the others avoid.

chi allocates for the request context. chi is compatible with net/http, so it stores URL parameters in the request's context, which means copying the request (WithContext) and allocating. 170 to 355 ns is what that standard compatibility costs here. In exchange, every http.Handler and middleware works with it unchanged.

ServeMux is competitive. Since Go 1.22 the standard multiplexer supports methods and wildcards (GET /orders/{id}), and it did the one parameter lookup in 120 ns with one allocation. For most services, that is as fast as they will ever need, with no dependency. The two parameter nested route took 232 ns, about twice, because the matcher walks segments.

The not found row is a different thing. ServeMux took 767 ns and 16 allocations for a path that matches nothing, against 40 ns for gin. In ServeMux the miss builds a 404 response through http.NotFound, with header allocation and a body write, and in the lab the other routers' default handlers do far less. If your server is on the public internet, scanners will hit unknown paths all day, so a custom, cheap 404 handler is worth setting.

Why it rarely matters

Take the slowest figure in the table, 355 ns for chi on a nested route. At 10,000 requests a second that is 3.6 ms of CPU per second, 0.36 percent of a core. At 100,000 requests a second it is 3.6 percent of a core. The same service pays for TLS, reading the request, a handler that calls a database for 1 to 10 ms, and writing a JSON response of tens of microseconds. A router that is five times faster saves 0.3 µs of a request that takes 2,000 µs.

A router would matter in three circumstances: a gateway or proxy whose handler does almost nothing and which runs at several hundred thousand requests a second per core; a service with tens of thousands of routes, where tree depth and allocation start to count; or an embedded or serverless setting where cold start and memory dominate. Even then, profile first (CPU profiles). A profile of a typical service shows the router at under 1 percent.

What the benchmark does not include

  • Middleware. Logging, authentication, compression and recovery cost microseconds each, ten to a hundred times the router.
  • Real clients. Connection handling, TLS and HTTP parsing dominate in net/http's server regardless of the router.
  • Route count at scale. 50 routes is modest. Radix routers scale with path length; ServeMux also uses an index, but measure with your route table if it has thousands.
  • Regular expressions and constraints. None of these used them; routers that support them pay for them.
  • Maintenance and fit. Whether you need route groups, a binder, OpenAPI integration or an existing middleware ecosystem is a larger part of the decision.

Choosing

  1. Start with net/http ServeMux if you need methods and path parameters and little else. It has no dependency and was fast here.
  2. Use chi if you want route groups, a large library of standard compatible middleware, and the ability to mix with any http.Handler. Pay 100 to 250 ns.
  3. Use gin if you want its binding, validation and rendering helpers, and accept its context type.
  4. Use httprouter for a minimal, fast router with its own handle signature and no framework around it, and use its native Handle to avoid the adapter cost.
  5. Do not choose on this table alone. Choose on features, then measure your own service.

Checklist

  • All four routed in under 0.4 µs with 50 routes; the spread is a rounding error next to a database call.
  • gin and httprouter native are fastest; chi pays for the standard request context.
  • ServeMux is fast enough for most services since Go 1.22.
  • A custom 404 handler is worth the lines, since the default is the most expensive path in ServeMux.
  • Profile your service before replacing a router for speed.

The routers are documented at chi, httprouter and gin, and the standard multiplexer in the net/http documentation.

Also inWeb
Taggedbenchmark
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.