Updated Aug 7, 2026

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 escape

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

The -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 efficient

Measuring 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

  1. Profile first — use pprof to find the actual hotspot (lesson 14)
  2. Benchmark the hotspot — establish a baseline with -benchmem
  3. Make one change — don't optimize multiple things at once
  4. Measure again — use benchstat to verify the improvement is real
  5. 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.Pool reuses expensive objects across goroutines; always Reset() 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.Builder or strings.Join instead of + concatenation in loops
  • Always benchmark before and after with -benchmem and verify with benchstat
  • 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?

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy