Skip to main content

Advanced Guide

Routing Performance

GoFr routes on gorilla/mux, which finds a handler by walking the registered routes in order and testing each one against the request path. That is O(n) in the number of routes, so a service pays a little more per request for every route it adds.

Setting GOFR_ROUTER=trie swaps the matching step for a segment trie, making it O(path length) — flat as the route table grows. Everything else is unchanged: mux is still the route registry, and mux still makes the final decision about which route matches.

Bash
# configs/.env
GOFR_ROUTER=trie

It is off by default. Leave it unset and your service behaves exactly as it always has.

Whether it will help you

The win scales with the size of your route table, so it is worth being concrete about where the line is. Measured on an Apple M4, with the request hitting the middle of the table:

RoutesDefault (mux)GOFR_ROUTER=trieSpeedup
1431 ns447 ns0.96x
10506 ns461 ns1.1x
501007 ns550 ns1.8x
1001642 ns548 ns3.0x
2002918 ns515 ns5.7x

The crossover is around 5–10 routes. Below that the trie is marginally slower, so a small service gains nothing by turning it on.

Two further caveats worth setting expectations against:

  • Matching is a minority of a request. The middleware chain — tracing, logging, metrics, CORS — dominates. So end-to-end throughput moves by less than the table above, approaching it only as the route count grows.
  • The trie also allocates less, where it used to allocate more. On a 100-route table, per matched request: a static route costs 536 B / 8 allocations against the default matcher's 960 B / 12, and a parameterised route 1208 B / 11 against 1264 B / 13. The middleware chain is composed once per route instead of per request, and an empty path-parameter map is no longer stored. This applies to routes registered through the framework (app.GET, app.POST, ...).

What stays the same

Routing behavior is unchanged, and that is a property the framework tests for rather than a hope. The trie only narrows the set of routes worth considering; mux's own Route.Match still decides every request, so method matching, {id:[0-9]+} constraints, header and query matchers, route ordering and path cleaning all behave exactly as they do by default. Anything the trie cannot index — PathPrefix routes, static file handlers, slash-spanning parameters like {path:.*} — is handled by mux directly. Requests that match nothing are handed to mux in full.

Path parameters are unaffected: ctx.PathParam("id") returns the same values under either matcher. mux.Vars(r) returns the same values too, with one difference worth knowing: on a route that declares no path parameters the trie leaves it nil where mux returns an empty non-nil map. Every read behaves the same -- indexing gives the zero value, len is 0, and ranging does nothing -- but an explicit mux.Vars(r) != nil check answers differently.

Middleware registration becomes order-sensitive. Each route's middleware chain is composed once, on its first request, and reused, so a middleware registered after a route has served does not run for that route. Registering everything before starting the server — which is what app.Run does, and what an application normally does — keeps this invisible. An application that reaches the router itself and registers late gets an error in the log saying so rather than a middleware that silently never runs.

The one thing to check in your own code

The trie serves matched requests without going through mux's own ServeHTTP, which is what populates mux.CurrentRoute. If any of your handlers or middleware calls it:

Go
// Returns nil when GOFR_ROUTER=trie.
route := mux.CurrentRoute(r)
tmpl, _ := route.GetPathTemplate()

use GoFr's accessor instead. It resolves the template under both routers, so it is safe to adopt before you flip the flag:

Go
import gofrHTTP "gofr.dev/pkg/gofr/http"

tmpl := gofrHTTP.RouteTemplate(r) // "/users/{id}", or "" if nothing matched

mux.Vars(r) keeps working and needs no change for any ordinary read. Only an explicit nil check against the map itself differs -- see the note above.

Confirming which matcher is active

GoFr logs the matcher at startup whenever GOFR_ROUTER is set:

text
INFO  HTTP route matcher: trie

A value it does not recognize falls back to mux and says so, so a typo does not cost you the opt-in silently:

text
WARN  unrecognized GOFR_ROUTER value "tri", using the "mux" router; valid values are "mux" and "trie"

A note on registering routes late

The index is built once, from the routes present when the first request arrives. GoFr registers every route during startup, before the server begins accepting requests, so this holds for all framework code paths. A route added after the server is already serving would not be indexed — it would still be served correctly, via mux, just without the speedup.