perf: cut per-request overhead from eight allocations to one - #108
Merged
Merged
Conversation
Every response built its Content-Type by concatenating a constant content type with a constant charset, then handed it to Header().Add, which allocates a one-element []string for the value. The Server header did the same. Both values are known at compile time, and "Content-Type" and "Server" are already the canonical forms net/http would derive, so the header can be assigned from a package-level value instead: three allocations per response become none. Call.charset made the charset look configurable, but it was assigned utf-8 at construction and written nowhere else, so the content type is a constant either way. headers.ContentTypeHeader loses its last caller with it. Text and HTML now replace an existing Content-Type rather than appending a second one. A response with two Content-Type headers is malformed under RFC 9110 section 8.3, so the handler that wrote the body decides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A request allocated three objects that all live exactly as long as it does: the Call, the recording responseWriter it wraps around net/http's writer, and the interface variable Raw.W points at. The writers are now fields of the Call and point into it, so the whole per-request state is one allocation instead of three. newCallFromRequest returns a *Call for it: the writers reference the call they belong to, so a returned copy would leave Raw.W aimed at the original. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every request formatted a UUIDv4 into a string whether or not anything read it. Access logging is off by default and a handler need never call ID(), so the common request paid for an ID nobody looked at. ID() now mints one the first time it is asked and keeps it, which is the whole cost for a request that does read it and none for a request that does not. The ID is no longer fixed at construction, so a handler that fans out to goroutines should read it before it does. That matches the rest of Call, whose status, path params and buffered body are already the serving goroutine's alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Text and HTML converted their string to []byte to hand it to Write, allocating a copy of every response body. *http.response implements io.StringWriter, so the wrapper can offer WriteString and pass the string through to it, keeping the byte-slice conversion only for a wrapped writer that cannot take one. The benchmark's writer gains WriteString with it. It stands in for *http.response, and without the method it would have measured a copy no real server makes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pkkummermo
force-pushed
the
perf/per-request-constant
branch
from
August 22, 2026 19:25
1be383a to
c4e51ed
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #107. That PR removed the allocations that scaled with the route table; this one removes the constant per-request overhead that every request pays regardless of routing.
A text response goes from 8 allocations to 1, and from ~232 ns to ~120 ns in-process on an M4. For reference on the same machine and harness, gin answers the same shape in ~90 ns / 1 alloc and a bare
http.ServeMuxin ~73 ns / 2 allocs.Four independent changes, one per commit:
Content-Typewas concatenated from two constants and passed toHeader().Add, which allocates a one-element[]string;Serverdid the same. Three allocations become none.Raw.Wbecome fields of theCall. Three heap objects with identical lifetimes become one.ID()instead of at construction, so a request nobody asks the ID of never formats a UUID.[]bytecopy viaio.StringWriter, which*http.responseimplements.Behaviour changes worth a reviewer's attention
TextandHTMLnow replace an existingContent-Typeinstead of appending a second one. A handler that set the header itself and then wrote a body used to produce twoContent-Typeheaders, which is malformed under RFC 9110 §8.3. Pinned byTestBodyWriterReplacesAnExistingContentType.ID()now mutates theCallon first read, so a handler that fans out to goroutines should read the ID before it does.Call's status, path params and buffered body were already the serving goroutine's alone; this brings the ID in line rather than out of it.Call.charsetis gone with the first commit — it was assignedutf-8at construction and written nowhere else, so it made the charset look configurable when it never was.headers.ContentTypeHeaderloses its last caller with it.Allocation budgets move
8/9/5/8/33/6/39→1/3/1/1/27/2/33, one step per commit.🤖 Generated with Claude Code