A single 32-byte allocation per request sounds harmless until you multiply it by
a hundred thousand requests a second. That is the story of this change: a
timestamp that was quietly generating megabytes of garbage per second inside the
S3 CopyObject response path.
An allocation is memory claimed from the heap, the shared pool the garbage collector manages. A hot path is code executed on nearly every request. Go’s profiler, pprof, is what let us see the allocation in the first place: it samples a running program and attributes time and memory to the functions that consume them.
The Real Problem
During a sustained S3 load test — 100k requests per second of CopyObject — the
garbage-collector trace revealed a surprising hotspot: time.Format was
allocating 32 bytes per request just to render the LastModified timestamp
in the XML response. At peak throughput that is roughly 3 MB/s of garbage only
for timestamps, and the GC pause spiked to 12 ms — well past our 5 ms p99
latency target.
We did not catch this in unit tests. It only appeared under sustained load, when the allocation rate overwhelmed the generational collector’s ability to keep up. The “small allocation” was not small at scale.
Why the Obvious Solutions Failed
First attempt: pool pre-formatted buffers.
| |
The pool’s mutex contention at 100k operations per second added more latency than the allocation itself. And pooled objects still pressure the collector — they just delay it, and risk promotion to the old generation where pauses are longer.
Second attempt: cache formatted timestamps. Timestamps are unique per request at nanosecond precision, so a cache would have a near-zero hit rate and only waste memory.
Third attempt: pre-allocate a buffer and use fmt.Sprintf.
| |
Formatting through fmt goes through reflection and interface conversions, which
allocate regardless of how the destination buffer was prepared.
The Solution — Stack-Buffer Time Formatting
The standard library provides time.AppendFormat specifically for this. It
writes directly into a byte slice and returns the extended slice — no
intermediate string, no heap allocation.
| |
Key insight: timeBuf[:0] creates a slice with length 0 but capacity 32,
backed by the stack array. AppendFormat appends into that capacity, so the
result points into the stack array and never escapes to the heap.
Principle Callout
Pattern: Stack-Buffer Time Formatting Use
time.AppendFormatwith a stack-allocatedbytearray in hot paths. It eliminates the heap allocation per call.Applies when: high-frequency timestamp rendering — HTTP headers, XML/JSON fields, log lines. Doesn’t apply: one-off formatting or human-readable output, where the allocation is negligible.
1 2 3 4 5 6 7 8 9 10 11// ✅ GOOD: Hot path — HTTP header, XML field, log line func writeLastModified(buf *bytes.Buffer, modTime int64) { var timeBuf [32]byte t := time.Unix(0, modTime).UTC() buf.Write(t.AppendFormat(timeBuf[:0], time.RFC3339Nano)) } // ❌ AVOID: Low-frequency path — allocation negligible func formatForDisplay(modTime int64) string { return time.Unix(0, modTime).Format("Jan 2, 2006") }
Verification
| Metric | Before | After | Delta |
|---|---|---|---|
| Allocs/op | 1 | 0 | -100% |
| Bytes/op | 32 B | 0 B | -100% |
| ns/op | 451 | 463 | +2.6% (within noise) |
| GC pause (p99) | 12 ms | <1 ms | -92% |
The paired microbenchmarks isolate the two implementations, and under 100k requests per second of production-like load the GC cycles dropped from 45 per minute to 3, with p99 latency settling at 3.2 ms.
Failure Modes
| Risk | Mitigation |
|---|---|
| Buffer too small | 32 bytes covers RFC3339Nano (20) + .000Z (5) + padding; it is a compile-time constant |
| Timezone edge cases | .UTC() normalizes before formatting, so output is consistent |
| Thread safety | The stack buffer is per-call — no sharing, no races |
When NOT to Use This
- Request IDs, trace IDs, and user-facing dates — these are not hot paths.
- One-off formatting in startup or shutdown paths.
- When you need a
stringreturn value for API compatibility; the allocation is unavoidable at that boundary.
References / Dig deeper
- Mindset standards: docs/CORE/STANDARDS.md — ⚡ Bolt / 🛡 Sentinel.
- Bolt engineering overview: 024.
- The same pattern for HTTP
Last-Modifiedheaders: 045. - Applied to
ListPartsXML: 048. - The change spec:
openspec/changes/bolt-s3-copyresult-time-alloc. - The paired microbenchmarks live in
src/s3/response_test.go(BenchmarkCopyObjectResponse_AppendFormatvsBenchmarkCopyObjectResponse_Format).