# The Zero-Allocation Secret: Context Pool and smartParams

> How gortex's routing hot path reaches 0 allocs/op: sync.Pool Context reuse, a [4]string smartParams, an embedded responseWriter, and how to measure it.

Source: https://sheng.page/en/posts/gortex-zero-allocation-context/ · Published: 2026-04-05 · Author: Sheng

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.

> Prerequisite: [Memory and Sync Primitives](/en/posts/go-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:

```go
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:

```go
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**: the matching in [The Segment-Trie Router: Matching Without Regex](/en/posts/gortex-segment-trie-router/) 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:

```go
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](/en/posts/go-interfaces-and-composition/):

```go
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`:

```go
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.


## Notes

- 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 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](https://github.com/yshengliao/gortex).

---

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