Go’s bytes.Buffer.Peek: A 200x Performance Win Hidden in Plain Sight

How a 5-line function can save 4GB of memory allocations

分享
Go’s bytes.Buffer.Peek: A 200x Performance Win Hidden in Plain Sight
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:

  1. Allocates a 4KB internal buffer (even if you have 10 bytes)
  2. May copy data into that buffer
  3. 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:

  1. bufio.Reader (current workaround)
  2. bytes.Buffer (direct, when possible)
  3. CustomBuffer with 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/op

200x 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/op

Four 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 allocs

Scale 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/op

16x 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 B

With Buffer.Peek

1. Slice indexing                 ~1.5 ns 
   return b.buf[off:off+n] 
Total: ~1.5 ns + 0 B

It’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:

  1. Streaming I/O (data not in memory yet → bufio is better)
  2. Large read-ahead needed (bufio’s prefetching helps)
  3. 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:

  1. False sharing on M4’s unique memory architecture
  2. Standard library’s years of optimization
  3. 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.go

Expected 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

References

  1. Go issue #73794: proposal: bytes: add Buffer.Peek
  2. Zero-copy techniques in Go
  3. Too Much Go Misdirection (original motivation)