This episode polishes performance: efficient route patterns, http.Server tuning, connection pooling, and caching with Redis. You will also learn to solve common problems such as route pattern conflicts, panic handlers, context deadlocks, goroutine leaks, and strategies for upgrading between chi versions.

A correct application isn't necessarily a fast one. Episode 19 closes the technical side: how to squeeze out performance without sacrificing correctness, then how to solve the classic problems that haunt every production Go project.
The first part covers optimization: route patterns, server tuning, pooling, and caching. The second part covers troubleshooting: symptoms, root causes, and solutions for the five most common problems in the chi ecosystem.
chi's radix tree works best with regular patterns:
r.Get("/users", listUsers)
r.Get("/users/{id:[0-9]+}", getUser)
r.Post("/users", createUser){id:[0-9]+} is more efficient than a free param for numeric identifiers — the regex is more specific, so fewer candidates are tried. Avoid the {path:*} wildcard on frequently called routes unless it's really needed.
Two routes that could both match the same request slow down matching and cause confusion. Make patterns as specific as possible and use subrouters to group paths that share a prefix — the radix tree shares nodes automatically.
Server configuration provides load protection:
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}ReadTimeout limits the time to read a request; WriteTimeout limits response writing. MaxHeaderBytes prevents giant headers. These values reduce the odds of slow attacks and hanging connections.
Without timeouts, a slow connection can hold a goroutine forever — each connection consumes a goroutine, and hanging goroutines drain memory. The values above are a reasonable starting point for a JSON API.
The pool you've used since episode 9 needs to be set for real load:
pool.Config().MaxConns = 20
pool.Config().MinConns = 5
pool.Config().MaxConnLifetime = time.Hour
pool.Config().MaxConnIdleTime = 5 * time.MinuteMaxConns caps the maximum parallel connections to the database; MinConns keeps ready-to-use connections. MaxConnLifetime forces connection rotation so stale connections aren't reused. The right size depends on the database spec.
Frequently requested responses shouldn't be recomputed:
go get github.com/redis/go-redis/v9client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
func cacheOrFetch(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
val, err := client.Get(ctx, "popular").Result()
if err == nil {
writeJSON(w, http.StatusOK, val)
return
}
data := fetchSlowData(ctx)
client.Set(ctx, "popular", data, time.Minute)
writeJSON(w, http.StatusOK, data)
}client.Get(ctx, "popular") reads the cache first; if it's empty, the data is computed and stored with client.Set(ctx, "popular", data, time.Minute). The one-minute TTL keeps the cache from going stale forever.
Don't cache sensitive per-user data under a global key, and don't cache rapidly changing data without invalidation. The best caches are for data that rarely changes and is expensive to compute.
Symptom: a route is registered but never matches, or a panic at startup. The fix: chi's radix tree forbids two ambiguous patterns at the same position. Check the order — literals must win over params, and don't register two wildcards in the same segment.
Symptom: the server dies suddenly without logs. The fix: put middleware.Recoverer at the top of your r.Use calls:
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)middleware.Recoverer prints a stack trace to the log — the primary clue for finding the panicking line.
Symptom: a handler hangs, timeouts never fire. Cause: a context isn't forwarded, or a channel waits without a select on ctx.Done(). The fix: always select with <-ctx.Done() (episode 12) and forward req.Context() to every operation.
Symptom: memory keeps rising even under stable load. Cause: goroutines without an exit path, for example a go func() writing to an unbuffered channel with no reader. The fix:
go func() {
select {
case ch <- result:
case <-ctx.Done():
}
}()select { case ch <- result: case <-ctx.Done(): } gives the goroutine an exit path when the request is cancelled. Monitor with pprof go tool pprof to find hanging goroutines.
Symptom: old code breaks after go get of a new version. The fix: read the release notes, run tests first, and upgrade gradually:
go get github.com/go-chi/chi/v5@latest
go mod tidy
go test ./...go get github.com/go-chi/chi/v5@latest followed by go test ./... ensures the upgrade doesn't break behavior. We cover per-version feature details in episode 20.
Key takeaways:
ReadTimeout and WriteTimeout protect against slow requests.In the next episode 20 we look at the latest: the newest stable features in chi v5.2 and v5.3 — support for the last four Go versions, middleware.Discard and the RedirectSlashes fix in v5.2, and inline middleware on subrouters, XML support in the default compressible types, the io/ioutil replacement, and Go 1.26 CI in v5.3.