Skip to content

perf: cut per-request overhead from eight allocations to one - #108

Merged
pkkummermo merged 4 commits into
mainfrom
perf/per-request-constant
Aug 22, 2026
Merged

pkkummermo merged 4 commits into
mainfrom
perf/per-request-constant

Conversation

@pkkummermo

Copy link
Copy Markdown
Owner

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.ServeMux in ~73 ns / 2 allocs.

shape before after
text 232 ns · 8 allocs 120 ns · 1
status only 178 ns · 5 109 ns · 1
json 343 ns · 9 241 ns · 3
before + after 335 ns · 8 222 ns · 1
200 routes / first match 443 ns · 8 322 ns · 1

Four independent changes, one per commit:

  1. Fixed header values are assigned, not built. Content-Type was concatenated from two constants and passed to Header().Add, which allocates a one-element []string; Server did the same. Three allocations become none.
  2. The response writer and Raw.W become fields of the Call. Three heap objects with identical lifetimes become one.
  3. The call ID is minted on first read of ID() instead of at construction, so a request nobody asks the ID of never formats a UUID.
  4. String bodies skip the []byte copy via io.StringWriter, which *http.response implements.

Behaviour changes worth a reviewer's attention

  • Text and HTML now replace an existing Content-Type instead of appending a second one. A handler that set the header itself and then wrote a body used to produce two Content-Type headers, which is malformed under RFC 9110 §8.3. Pinned by TestBodyWriterReplacesAnExistingContentType.
  • ID() now mutates the Call on 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.charset is gone with the first commit — it was assigned utf-8 at construction and written nowhere else, so it made the charset look configurable when it never was. headers.ContentTypeHeader loses its last caller with it.

Allocation budgets move 8/9/5/8/33/6/391/3/1/1/27/2/33, one step per commit.

🤖 Generated with Claude Code

pkkummermo and others added 4 commits August 22, 2026 21:24
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
pkkummermo force-pushed the perf/per-request-constant branch from 1be383a to c4e51ed Compare August 22, 2026 19:25
@pkkummermo
pkkummermo merged commit 7307853 into main Aug 22, 2026
11 of 12 checks passed
@pkkummermo
pkkummermo deleted the perf/per-request-constant branch August 22, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant