The Zero-Allocation Secret: Context Pool and smartParams

Gortex’s routing hot path measures 0 allocs/op. It gets there through sync.Pool Context reuse, a [4]string smartParams, and an embedded responseWriter. The benchmark at the end checks that those choices still avoid allocations.

Correction (2 November 2026, 18:45 Taipei): the gortex go.mod version in the footer is now Go 1.25 (decided by a vote of Codex (gpt-6-astra), Grok and Claude Fable); smartParams is now described as reused with the pooled Context rather than kept off the heap; interface and map operations are now described as possible, not certain, sources of allocation, and 0 allocs/op is limited to the steady state with a warm pool (suggested by Codex (gpt-6-astra), checked by Claude).

Prerequisite: 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 the memory post, 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, no extra allocation
    vals     [4]string
    overflow []pathParam // only spill to a slice beyond 4
}

[4]string is a value array inside smartParams, and smartParams itself is held by a pointer field on the Context and reused along with the pooled Context, so the first four params go straight into the arrays with no per-request map or slice. 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: the matching in The Segment-Trie Router: Matching Without Regex 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 Interfaces and Composition:

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 — re-pointing at the new http.ResponseWriter during Reset, saving the cost of a fresh wrapper per request. 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). That is the steady state with a warm pool; first use, or a GC that clears the pool, still allocates. Interface boxing, escapes and map growth can all push allocs/op above 0, but whether they do depends on the actual types and on escape analysis. Syntax alone will not show whether an allocation happens. That is why it has to be measured.

Notes

  • Context is borrowed from and returned to a sync.Pool; cleanup keeps the map and params backing arrays, so with a warm pool the next request needs no new allocation.
  • smartParams uses a [4]string SBO so ≤4 params need no extra allocation; overflow is an insertion-ordered slice that the segment-trie router’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 the memory post’s material; escape analysis, pooling, sync primitives; landed on the framework’s hot path.

Source: yshengliao/gortex.


Ideas and technical judgement by Sheng; drafted with Claude · Go 1.25 (gortex go.mod) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.