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]string SBO so ≤4 params stay off the heap; overflow is an insertion-ordered slice that B3’s backtracking can truncate exactly.
  • responseWriter embeds http.ResponseWriter (no methods to rewrite) and is a value field in the pooled context, so status tracking is allocation-free.
  • Use go test -benchmem to read allocs/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.