HTTP lets a client ask a server a precise question: “only send me this object if it has changed.” The client answers with the last entity tag (ETag) it saw, and the server compares it against the current one. Done well, this turns a full object transfer into a few bytes. Done carelessly, the comparison itself allocates memory on every request — which is what this change fixed.
The Problem: An Allocation on Every Conditional Request
An ETag is an opaque identifier for a specific version of an object, usually a hash. A client sends it back in one of two headers:
If-None-Match— “send the body only if its tag is not in this list” (the basis of304 Not Modifiedand caching).If-Match— “apply this write only if the tag is in this list” (the basis of safe conditional writes).
The value is not always one tag. HTTP allows a comma-separated list:
| |
A tag may be quoted, and a W/ prefix marks a weak validator — one that
matches when the content is semantically equivalent, not byte-identical. There
is also the wildcard *, meaning “any version.” So the server has to walk the
list, normalise each entry, and compare it against the object’s current hash.
The old implementation walked the list with strings.Split:
| |
strings.Split allocates a slice header and a backing array to hold the
parts — even though the caller only ever looks at one entry at a time and then
discards it. On a busy gateway serving cached reads, that is one heap
allocation per conditional request, feeding the garbage collector for no
reason: the bytes are already in memory as a single string.
The Fix: Scan and Slice, Don’t Copy
Go strings are immutable and cheap to slice: s[i:j] produces a new string
header that shares the same bytes — no copy, no heap allocation.
strings.IndexByte finds a byte without allocating. Together they replace
Split with a single pass:
| |
The normalisation steps are untouched — the same TrimSpace, *, W/, and
quote-stripping logic runs on each entry, in the same order. Only the way the
entries are produced changed: from an allocated slice to a cursor over the
original string.
Principle — prefer slicing to splitting. When you only need offsets into a string, reach for
IndexByte+ slicing. ReserveSplitfor cases where you genuinely need independent substrings, and treat everySpliton a request path as a small tax the GC collects.
Verifying It
Two things must hold after a parser rewrite: nothing broke, and the cost fell.
The first is a table test. We converted the ETag-match test into a table
covering the cases the old and new code must agree on: quoted and unquoted
tags, weak tags (W/"…", and W/ "…" with a space), the wildcard alone and
inside a list, leading and trailing spaces, a trailing comma, a tag absent from
the list, an empty header, and an empty object tag. Every case returns the same
result as before.
The second is a microbenchmark — a benchmark that isolates one function.
BenchmarkEtagMatches reports allocations per operation, with
BenchmarkEtagMatchesSplit kept as the “before” baseline:
| ns/op | B/op | allocs/op | |
|---|---|---|---|
strings.Split (before) | ~257–296 | 80 | 1 |
IndexByte + slice (after) | ~127–181 | 0 | 0 |
The allocation count is the number that matters. Driving it to zero means the GC never sees these calls — regardless of how many conditional requests arrive.
Failure Modes
| Risk | Guard |
|---|---|
| A subtle parse difference changes matching | Table test asserts old and new agree on every input |
| A weak or wildcard tag stops matching | Dedicated cases for W/ and * (alone and in a list) |
| A sliced entry aliases and outlives the header | Entries are consumed immediately; the header string is request-scoped |
| The win is only a microbenchmark artifact | The split baseline runs the same header, so the delta is the allocation, not the input |
When NOT to Use This
When the parsed pieces must outlive the source or be modified. A slice
aliases the parent string and keeps it alive; Split returns owned copies. If
an entry is stored beyond the request, or written to, copy it. Here each entry
is normalised and compared in place, so slicing is safe. And as always: profile
first. This pattern earns its place only after a measurement points at the
parser.
References / Dig deeper
- The standard: docs/CORE/STANDARDS.md
- Related Bolt posts: 024 performance mindset, 048 the last XML allocation, 051 streaming-header parsing.
- Tracking issue: #1133.