15 - Performance Optimization
📋 Jump to Takeaways🎁 pprof showed you the hotspot. Now what do you actually do about it?
Profiling tells you where time and memory go. This lesson covers how to fix the most common findings: heap allocations in hot paths, interface overhead, buffer reuse, and string concatenation. The workflow is always the same: benchmark before, fix, benchmark after, verify with benchstat.
Escape Analysis
Go automatically decides whether a value lives on the stack or the heap. Stack allocations are free — the stack pointer just moves. Heap allocations cost: they trigger garbage collection, which causes latency spikes in production.
The compiler's decision is called escape analysis. You can see it:
go build -gcflags="-m" ./...
# ./main.go:12:9: &x escapes to heap
# ./main.go:18:6: y does not escapeA value "escapes" to the heap when it outlives the current function — returned by pointer, stored in an interface, captured by a goroutine, or stored in a heap-allocated struct.
func stack() int {
x := 42
return x // value copied out, x stays on stack
}
func heap() *int {
x := 42
return &x // address returned — x must escape to heap
}Understanding escape analysis helps you read pprof heap profiles. When you see unexpected allocations, check what the compiler is escaping and why.
sync.Pool: Reusing Objects
sync.Pool is a cache of temporary objects you can reuse instead of allocating new ones. It's safe for concurrent use and the GC can reclaim pooled objects when memory is needed.
import (
"bytes"
"sync"
)
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func processRequest(data []byte) string {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
buf.Write(data)
buf.WriteString(" processed")
return buf.String()
}Without the pool, every call to processRequest allocates a new bytes.Buffer. With the pool, buffers are reused across requests. Under load this can reduce allocations by orders of magnitude.
Rules for sync.Pool:
- Always
Reset()the object before returning it to the pool — the next caller will get your leftover data otherwise - Don't store state you care about in pooled objects — the GC can drop them at any time
- Only useful for objects that are expensive to allocate and frequently needed
The standard library uses sync.Pool internally in encoding/json, fmt, and net/http.
Pre-allocating Slices
Every time a slice grows beyond its capacity, Go allocates a new backing array and copies. In a hot path this is wasteful.
// Bad: grows repeatedly — multiple allocations
func collectBad(n int) []int {
var result []int
for i := 0; i < n; i++ {
result = append(result, i) // may reallocate several times
}
return result
}
// Good: allocate once with known capacity
func collectGood(n int) []int {
result := make([]int, 0, n) // capacity n, no reallocations
for i := 0; i < n; i++ {
result = append(result, i)
}
return result
}Benchmark the difference:
func BenchmarkBad(b *testing.B) {
for b.Loop() {
collectBad(1000)
}
}
func BenchmarkGood(b *testing.B) {
for b.Loop() {
collectGood(1000)
}
}go test -bench=. -benchmem
# BenchmarkBad-8 50000 24000 ns/op 25208 B/op 12 allocs/op
# BenchmarkGood-8 80000 15000 ns/op 8192 B/op 1 allocs/opThe -benchmem flag shows allocations per operation. One allocation vs twelve is a significant difference in a hot path.
Interface Allocations
Storing a concrete value in an interface can cause a heap allocation. The compiler allocates when it can't prove the value won't escape.
// Repeated interface boxing in a hot path
func sumInterface(values []any) int {
total := 0
for _, v := range values {
total += v.(int) // type assertion on every iteration
}
return total
}
// No interface — stays on stack
func sumConcrete(values []int) int {
total := 0
for _, v := range values {
total += v
}
return total
}In hot paths, prefer concrete types over interfaces. Use interfaces at API boundaries — where you need polymorphism — but keep the inner loop working with concrete types.
strings.Builder for String Concatenation
String concatenation with + allocates a new string on every operation. For building strings in a loop, use strings.Builder:
import "strings"
// Bad: O(n²) allocations
func joinBad(parts []string) string {
result := ""
for _, p := range parts {
result += p // new allocation every iteration
}
return result
}
// Good: single allocation
func joinGood(parts []string) string {
var b strings.Builder
b.Grow(100) // optional: pre-allocate if you know the size
for _, p := range parts {
b.WriteString(p)
}
return b.String() // one allocation at the end
}strings.Join from the stdlib does the same thing internally. Use it when you have a separator:
result := strings.Join(parts, ", ") // clean and efficientMeasuring the Impact
Never optimize without measuring before and after. Use benchstat to compare:
# Run benchmarks before the fix, save results
go test -bench=. -benchmem -count=5 > before.txt
# Make your change, then run again
go test -bench=. -benchmem -count=5 > after.txt
# Compare
benchstat before.txt after.txt │ before.txt │ after.txt │
│ sec/op │ sec/op vs base │
BenchmarkX-8 24.00µ ± 2% 15.00µ ± 1% -37.50% (p=0.008)A statistically significant improvement (low p-value) means the change was real, not noise.
The Optimization Workflow
- Profile first — use
pprofto find the actual hotspot (lesson 14) - Benchmark the hotspot — establish a baseline with
-benchmem - Make one change — don't optimize multiple things at once
- Measure again — use
benchstatto verify the improvement is real - Check correctness — run tests; a fast but wrong program is worse than a slow correct one
Premature optimization is a real cost. Every optimization adds complexity. Only pay that cost when profiling shows the bottleneck is where you think it is.
Key Takeaways
- Stack allocations are free; heap allocations trigger GC — use
go build -gcflags="-m"to see what escapes sync.Poolreuses expensive objects across goroutines; alwaysReset()before returning to the pool- Pre-allocate slices with
make([]T, 0, n)when you know the size to avoid repeated reallocation - Avoid interface boxing in hot paths — keep inner loops working with concrete types
- Use
strings.Builderorstrings.Joininstead of+concatenation in loops - Always benchmark before and after with
-benchmemand verify withbenchstat - Profile first, optimize second — never guess
🎁 Your code runs perfectly on your laptop. How do you package it so it runs the exact same way on a server you've never logged into, in an image smaller than a single photo?