The Zero-Allocation Secret: Context Pool and smartParams
The Zero-Allocation Secret: Context Pool and smartParams
Gortex’s routing hot path is 0 allocs/op. The secret is three things: sync.Pool Context reuse, a [4]string smartParams, and an embedded responseWriter. This post takes each apart and shows how to measure.
Prerequisite: A5 Memory and Sync Primitives.
Reusing Context with sync.Pool
Every request needs a Context. A new per request is tens of thousands of heap allocations a second, plus GC pressure. Gortex borrows from a sync.Pool instead:
var contextPool = sync.Pool{
New: func() any { return &DefaultContext{store: make(Map)} }, // pre-allocate the store map
}
func AcquireContext(r *http.Request, w http.ResponseWriter) Context {
c := contextPool.Get().(*DefaultContext)
c.Reset(r, w) // bind the new request/response
return c
}
The trick is in returning it. ReleaseContext scrubs the fields and puts it back, but keeps the allocated memory: the store is cleared with for k := range m { delete(m, k) } rather than a fresh make (the bucket array survives); params calls reset() instead of nil (the backing array survives). The next request gets a Context that’s clean but memory-ready — that’s the sync.Pool reuse from A5, applied.
smartParams: a [4]string small-buffer optimisation
Path parameters (the :id ones) would be another allocation if each request opened a map. Gortex uses a small-buffer optimisation:
type smartParams struct {
count int
keys [4]string // the common case (≤4 params) stays in fixed arrays, off the heap
vals [4]string
overflow []pathParam // only spill to a slice beyond 4
}
[4]string is a value array embedded in the Context, so it costs no allocation. Four is chosen because most REST paths don’t nest more than three or four params; anything more spills to overflow.
It’s deliberately an insertion-ordered slice, not a map: B3’s matching backtracks under Static > Param > Wildcard, and a failed param branch must drop exactly the params it added. truncate(n) relies on that order to discard everything at index ≥ n:
func (sp *smartParams) truncate(n int) {
sp.count = n
switch {
case n <= 4:
sp.overflow = sp.overflow[:0]
case n-4 < len(sp.overflow):
sp.overflow = sp.overflow[:n-4]
}
}
Embedding responseWriter
To record the response status and size (for the logger, and for the “already written?” check), you wrap http.ResponseWriter. Gortex’s wrapper uses the embedding from A3:
type responseWriter struct {
http.ResponseWriter // embedded: every original method inherited for free
status int
size int64
written bool
}
Embedding http.ResponseWriter makes the wrapper satisfy the interface automatically; it only overrides WriteHeader/Write to record status and size. More importantly, it’s a value field in DefaultContext (rw responseWriter), with the same lifetime as the pooled context — not a fresh wrapper per request, but one that Reset re-points at the new http.ResponseWriter. Status tracking therefore costs zero extra allocation.
Measuring allocs/op
Zero-allocation can’t be claimed, it has to be measured. go test -benchmem reports allocs/op:
func BenchmarkRoute(b *testing.B) {
r := setupRouter()
req := httptest.NewRequest("GET", "/users/42", nil)
w := httptest.NewRecorder()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
r.ServeHTTP(w, req)
}
}
// go test -bench=BenchmarkRoute -benchmem
// → ... ns/op 0 B/op 0 allocs/op
Per gortex’s README benchmark, the routing hot path is around 65 ns/op at 0 allocs/op (M3 Pro). Let a single value escape — into an interface, or a map write — and allocs/op is no longer 0. That’s why it’s measured, not guessed.
Takeaways
- Context is borrowed from and returned to a
sync.Pool; cleanup keeps the map and params backing arrays, so the next request is 0 alloc. - smartParams uses a
[4]stringSBO so ≤4 params stay off the heap; overflow is an insertion-ordered slice that B3’s backtracking cantruncateexactly. responseWriterembedshttp.ResponseWriter(no methods to rewrite) and is a value field in the pooled context, so status tracking is allocation-free.- Use
go test -benchmemto readallocs/op; gortex’s routing is ~65 ns/op at 0 allocs/op (README, M3 Pro). - Zero-allocation is A5 — escape analysis, pooling, sync primitives — landed on the framework’s hot path.
Source: yshengliao/gortex.
Outline by Sheng, drafted with Claude · Go 1.24 (gortex go.mod) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.