Back to Blog
Technical
December 20, 2024
10 min read

Go Performance Tips: Making Your Code Faster

Real performance tips from building Sysmon-CLI and Zvezda. No theory—just what actually made things faster.

Noam Favier
Developer & Founder
Go Performance Tips: Making Your Code Faster

Go Performance Tips: Making Your Code Faster

Go is fast. But it's not magic—you can still write slow Go code. Here's what I learned optimizing Sysmon-CLI and Zvezda.

Profile Before You Optimize

Your intuition about what's slow is wrong. Mine was wrong. Everyone's is wrong.

go test -bench=. -benchmem
go test -cpuprofile=cpu.prof
go tool pprof cpu.prof

For Sysmon-CLI, I spent a week optimizing metric collection before profiling showed that JSON serialization was the actual bottleneck. Whoops.

The pprof web interface (`-http=:8080`) is fantastic. Use it.

Memory is Your Enemy

Allocations kill performance. The GC is good, but not allocating beats any GC.

**Preallocate slices when you know the size:**

// Don't do this
var results []string
for _, item := range items {
    results = append(results, process(item))
}

// Do this
results := make([]string, 0, len(items))
for _, item := range items {
    results = append(results, process(item))
}

That tiny `len(items)` capacity hint prevents multiple allocations as the slice grows.

**Use sync.Pool for short-lived objects:**

var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func formatMetric(m Metric) string {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    // Use buf, then return it to pool
    return buf.String()
}

In Sysmon-CLI, this cut allocations by 80% in the hot path.

**String concatenation in loops is terrible:**

// Allocates on every iteration
var result string
for _, s := range parts {
    result += s
}

// One allocation
var builder strings.Builder
builder.Grow(estimatedSize) // if you know approximate size
for _, s := range parts {
    builder.WriteString(s)
}
result := builder.String()

Goroutines Aren't Free

"Just spawn a goroutine" is bad advice. Goroutines are cheap but not free.

**Use worker pools for bounded concurrency:**

func processConcurrently(items []Item, workerCount int) {
    jobs := make(chan Item, len(items))
    var wg sync.WaitGroup

    // Fixed number of workers
    for i := 0; i < workerCount; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for item := range jobs {
                process(item)
            }
        }()
    }

    // Feed work
    for _, item := range items {
        jobs <- item
    }
    close(jobs)
    wg.Wait()
}

Zvezda uses this pattern when scanning multiple repos. 4-8 workers is usually optimal—more doesn't help.

**Don't leak goroutines:**

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case job, ok := <-jobs:
            if !ok {
                return // channel closed
            }
            process(job)
        case <-ctx.Done():
            return // context cancelled
        }
    }
}

Leaked goroutines are memory leaks. They pile up. Use context for cancellation.

I/O: Batch and Buffer

**Database operations should be batched:**

// Don't
for _, record := range records {
    db.Exec("INSERT INTO ...", record)
}

// Do
tx := db.Begin()
stmt, _ := tx.Prepare("INSERT INTO ...")
for _, record := range records {
    stmt.Exec(record)
}
tx.Commit()

One transaction instead of N. Massive speedup.

**Buffer file I/O:**

file, _ := os.Open("large.log")
reader := bufio.NewReader(file)
// Now reads are buffered

Unbuffered I/O is syscalls on every read. Buffered I/O batches them. Easy win.

What Doesn't Matter (Usually)

**Reflection is slow.** True. But if you're doing it once at startup, who cares? Don't use code generation until profiling proves it's worth it.

**Function call overhead.** The compiler inlines aggressively. Small functions are fine. Don't flatten your code for "performance"—the compiler is smarter than you.

There's no `//go:inline` directive. The compiler decides. You can see what gets inlined with:

go build -gcflags='-m' 2>&1 | grep inline

**Integer vs float math.** Float operations are slightly slower, but unless you're doing millions of them in a tight loop, you won't notice.

Profiling is Your Friend

**CPU profiling:**

go test -cpuprofile=cpu.prof -bench=.
go tool pprof -http=:8080 cpu.prof

**Memory profiling:**

go test -memprofile=mem.prof -bench=.
go tool pprof -http=:8080 mem.prof

**Trace for concurrency issues:**

go test -trace=trace.out
go tool trace trace.out

The trace tool shows goroutine scheduling, blocking, and contention. It's how I found that Zvezda was spending 40% of time waiting on a mutex.

Benchmarking

Write benchmarks for your hot paths:

func BenchmarkMetricCollection(b *testing.B) {
    collector := NewCollector()
    b.ResetTimer()

    for i := 0; i < b.N; i++ {
        collector.Collect()
    }
}

Run with:

go test -bench=. -benchmem -benchtime=10s

Compare before/after optimizations with `benchstat`.

Sysmon-CLI Case Study

Sysmon-CLI collects system metrics every second. Initial version used 50MB RAM and 5% CPU.

After optimization:

  • Preallocated metric buffers: -30MB
  • sync.Pool for temp objects: -15MB
  • Worker pool instead of goroutine-per-metric: -3% CPU
  • Buffered channel for metric reporting: -1% CPU
  • Final: ~8MB RAM, <1% CPU on typical machines.

    The key: profile, optimize the hot path, measure again. Repeat.

    When NOT to Optimize

    Don't optimize if:

  • It's not in your profiler's top 10
  • It runs once at startup
  • It makes the code unreadable
  • You're guessing instead of measuring
  • Readability beats premature optimization. Always.

    The Tools I Use

    **pprof**: CPU, memory, goroutine, mutex profiling. Built into Go.

    **benchstat**: Compare benchmark results statistically.

    **trace**: Visualize concurrency and scheduling.

    **Sysmon-CLI**: Monitor the actual production system. CPU spikes? Memory leaks? You need to see them in real-time.

    Final Thoughts

    Go is fast out of the box. Most code doesn't need optimization.

    When you do need speed:

    1. Profile first

    2. Fix the hot path

    3. Benchmark the change

    4. Profile again

    And remember: the fastest code is code you don't run. Sometimes the best optimization is algorithmic, not tactical.

    GoPerformanceOptimizationProfiling