Go’s bytes.Buffer.Peek: A 200x Performance Win Hidden in Plain Sight
How a 5-line function can save 4GB of memory allocations
2025-11-06 update : #73795 has been merged.
The Problem Nobody Talks About
You’ve probably written code like this a thousand times:
imageData := loadImage("photo.png")
buf := bytes.NewBuffer(imageData)
reader := bufio.NewReader(buf) // Why do we need this?
img, _, err := image.Decode(reader)That bufio.NewReader wrapper? It costs you:
- 4KB of memory allocation
- 2 heap allocations
- ~300ns of overhead
- Unnecessary GC pressure
Every. Single. Time.
Why This Happens
image.Decode needs to peek at the first few bytes to detect the format (JPEG? PNG? GIF?). But bytes.Buffer doesn't have a Peek method. So you're forced to wrap it in bufio.Reader, which:
- Allocates a 4KB internal buffer (even if you have 10 bytes)
- May copy data into that buffer
- Adds object allocation overhead
The irony? Your data is already in memory. You don’t need buffering. You need to look at it.
The Solution: bytes.Buffer.Peek
Go issue #73794 proposes adding: (release in Go 1.26)
// Peek returns the next n bytes without advancing the buffer.
func (b *Buffer) Peek(n int) ([]byte, error)The implementation is almost trivial:
// https://github.com/golang/go/compare/master...icholy:go:buffer-bytes-peek#diff-e3afb22a9a640f6ca0ee56597ce7190a743d2a2c770aabeeecc5accb1790bdfcR85
func (b *Buffer) Peek(n int) ([]byte, error) {
if b.Len() < n {
return b.buf[b.off:], io.EOF
}
return b.buf[b.off:n], nil
}That’s it. No allocations. No copies. Just pointer arithmetic.
The Numbers (Apple M4)
I ran benchmarks comparing three approaches:
bufio.Reader(current workaround)bytes.Buffer(direct, when possible)CustomBufferwith Peek (proposed solution)
Raw Peek Operation: 200x Faster
BenchmarkPeekOperation/bufio/1024 305.8 ns/op 4144 B/op 2 allocs/op
BenchmarkPeekOperation/custom/1024 1.5 ns/op 0 B/op 0 allocs/op200x speedup. Zero allocations. This isn’t a typo.
Why? Because:
bufio.NewReader: ~293ns + 4KB allocation- Slice indexing: ~1.5ns, compiler can inline it
Format Detection: 86x Faster
When checking multiple formats (JPEG, PNG, GIF, WebP):
BenchmarkFormatSniffing/bufio 302.1 ns/op 4144 B/op 2 allocs/op
BenchmarkFormatSniffing/custom 3.5 ns/op 0 B/op 0 allocs/opFour Peek operations: 302ns → 3.5ns. 86x faster.
Real-World image.Decode: 1–2% Faster
Small images (100x100):
bufio: 66,851 ns/op 91,700 B/op 17 allocs/op
custom: 65,798 ns/op 87,492 B/op 15 allocs/op
↓ ↓ ↓
1.6% faster 4.2KB saved 2 fewer allocs“Only 1.6%?” Yes, because format detection is ~0.5% of total decode time. The 99.5% is the actual image decoding.
But that 4KB saved per image matters at scale.
Batch Processing: Cumulative Impact
Processing 20 small images:
bufio: 383,432 ns 1,213,207 B 340 allocs
custom: 378,831 ns 1,129,047 B 300 allocs
↓ ↓ ↓
1.2% faster 82KB saved 40 fewer allocsScale to 1 million images:
- 4GB memory saved
- 2 million fewer allocations
- Significantly reduced GC pressure
Memory Operations: 16x Faster
Peek + Read pattern:
bufio: 430.4 ns/op 5168 B/op 3 allocs/op
custom: 26.3 ns/op 0 B/op 0 allocs/op16x faster for common patterns.
Understanding the Performance Gap
Let’s trace what happens:
With bufio.Reader
1. Allocate Reader struct ~100 ns
2. Allocate 4KB buffer ~100 ns
3. Initialize internal state ~50 ns
4. Peek logic ~50 ns
Total: ~300 ns + 4144 BWith Buffer.Peek
1. Slice indexing ~1.5 ns
return b.buf[off:off+n]
Total: ~1.5 ns + 0 BIt’s literally a one-instruction vs memory-allocation dance.
The Zero-Copy Principle
Here’s the key insight:
// Copying approach (what you might expect)
func PeekBad(b *Buffer, n int) []byte {
result := make([]byte, n) // Allocate
copy(result, b.buf[b.off:]) // Copy
return result
}
// Zero-copy approach (what actually happens)
func Peek(b *Buffer, n int) []byte {
return b.buf[b.off : b.off+n] // Just return a slice
}Memory layout:
Buffer internal array:
[h][e][l][l][o][ ][w][o][r][l][d]
↑ ↑
buf[0] off=6
Peek(5) returns:
[w][o][r][l][d]
↑
Points to same underlying array!No allocations. No copies. Just a new slice header pointing to existing memory.
When This Matters Most
Excellent for:
Format detection (86x faster)
buf := bytes.NewBuffer(fileData) header, _ := buf.Peek(16) format := detectFormat(header)Protocol parsing (200x faster)
buf := bytes.NewBuffer(packet)
header, _ := buf.Peek(4)
msgType := parseHeader(header)Batch file processing (cumulative benefits)
for _, file := range files {
buf := bytes.NewBuffer(file.Data)
process(buf) // 4KB + 2 allocs saved each iteration
}Low-latency services (reduced GC pauses)
Not great for:
- Streaming I/O (data not in memory yet → bufio is better)
- Large read-ahead needed (bufio’s prefetching helps)
- Certain concurrent patterns (more below)
The Concurrent Anomaly
Interestingly, my concurrent benchmark showed the custom implementation to be slower:
BenchmarkConcurrent/bufio 79,041 ns/op 215,228 B/op 17 allocs/op
BenchmarkConcurrent/custom 143,431 ns/op 211,018 B/op 15 allocs/op
↑
81% slower?!Possible reasons:
- False sharing on M4’s unique memory architecture
- Standard library’s years of optimization
- Test methodology (creating new buffers per goroutine)
Solution: Use sync.Pool:
var bufPool = sync.Pool{
New: func() interface{} {
return &Buffer{}
},
}
// In hot path
buf := bufPool.Get().(*Buffer)
buf.Reset(data)
defer bufPool.Put(buf)Try It Yourself
Complete benchmark code: GitHub link
go test -bench=. -benchmem peek_test.goExpected results:
- Peek operations: ~200x faster
- Format detection: ~86x faster
- image.Decode: ~1–2% faster, 4KB saved
- Batch processing: cumulative benefits
Conclusion
bytes.Buffer.Peek is a slight change with an outsized impact:
- Trivial implementation (5 lines)
- Zero-copy semantics
- Massive performance gains where it matters
- Better API ergonomics (no forced bufio wrapping)
The Go team is considering this for Go 1.24. It’s a rare win-win: simple implementation, significant benefits, no breaking changes.
If you work with binary formats, protocol parsing, or image processing in Go, you should care about this.
Appendix: Full Benchmark Results
goos: darwin
goarch: arm64
cpu: Apple M4
BenchmarkPeekOperation/bufio/16 305.8 ns/op 4144 B/op 2 allocs/op
BenchmarkPeekOperation/custom/16 1.5 ns/op 0 B/op 0 allocs/op
BenchmarkImageDecodeScenario/small/bufio 66,851 ns/op 91,700 B/op 17 allocs/op
BenchmarkImageDecodeScenario/small/custom 65,798 ns/op 87,492 B/op 15 allocs/op
BenchmarkFormatSniffing/bufio 302.1 ns/op 4144 B/op 2 allocs/op
BenchmarkFormatSniffing/custom 3.5 ns/op 0 B/op 0 allocs/op
BenchmarkPeekThenRead/bufio 4,556 ns/op 66,704 B/op 14 allocs/op
BenchmarkPeekThenRead/custom 4,110 ns/op 62,496 B/op 12 allocs/op
BenchmarkMemoryUsage/bufio 430.4 ns/op 5168 B/op 3 allocs/op
BenchmarkMemoryUsage/custom 26.3 ns/op 0 B/op 0 allocs/op
BenchmarkBatchProcessing/bufio 383,432 ns 1,213,207 B 340 allocs
BenchmarkBatchProcessing/custom 378,831 ns 1,129,047 B 300 allocs