In production Go services, garbage collection can become a silent performance killer. When your API endpoints handle thousands of requests per second, every allocation matters—and we've seen request latency jump by 20-30% due to unnecessary memory pressure in seemingly tight loops. Reducing allocations in Go: patterns, pitfalls and fixes is not about premature optimization; it's about understanding where allocations happen and applying proven patterns that teams across e-commerce, trading, and SaaS platforms use to keep their services fast.
Why allocations matter in production
Go's garbage collector runs concurrently, but it still costs CPU cycles and memory bandwidth. Each allocation is a tiny debt: the GC must track it, scan it during mark phases, and eventually collect it. In high-throughput systems, these debts compound. We've debugged latency spikes that traced back to allocations happening in the hot path—handlers that fire millions of times per day.
The good news is that most allocation problems are fixable with straightforward patterns. Let's walk through them.
Preallocate slices and maps
When you create a slice or map without a capacity hint, Go must grow it dynamically. Each growth triggers a reallocation and a copy of existing elements. In a handler that builds a response slice, this can happen several times in a single request.
The pattern: If you know or can estimate the final size, allocate upfront.
// Bad: slice grows during loop
var results []User
for _, id := range userIDs {
u := fetchUser(id)
results = append(results, u) // allocation on growth
}
// Good: preallocate
results := make([]User, 0, len(userIDs))
for _, id := range userIDs {
u := fetchUser(id)
results = append(results, u) // no reallocation
}For maps, the same principle applies:
// Bad
cache := make(map[string]int)
for _, item := range items {
cache[item.Key] = item.Value // map grows as needed
}
// Good
cache := make(map[string]int, len(items))
for _, item := range items {
cache[item.Key] = item.Value // allocated upfront
}In one CRM integration we worked on, a batch endpoint that processed 1000 records per request was building a results map without preallocation. Switching to preallocated maps reduced allocations by 60% and cut handler latency by 15%.
Reuse buffers with sync.Pool
For short-lived, frequently allocated objects—especially buffers—sync.Pool is your friend. It's a garbage-collection-aware cache that lets goroutines reuse temporary objects across requests. The trade-off is simplicity for speed, and in APIs handling thousands of RPS, the trade is worth it.
A common use case is JSON encoding or string building:
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
buf.Reset() // reuse, don't recreate
encoder := json.NewEncoder(buf)
encoder.Encode(data)
w.Write(buf.Bytes())
}The pool holds onto buffers between requests. When a handler needs one, it grabs it; when done, it returns it. The GC respects the pool and doesn't collect buffers that are sitting idle in it.
We've seen `sync.Pool` cut allocation counts by 70-80% for services that encode large JSON responses. One ride-hailing platform's API was creating a new bytes.Buffer for every response; switching to a pool reduced GC pause times from ~2ms to <0.5ms.
Avoid converting between string and []byte in loops
String-to-byte-slice conversions trigger allocations. They seem free because the conversion syntax is simple—`[]byte(str)`—but Go must allocate a new slice and copy the bytes. If you do this inside a loop over many strings, you're making unnecessary work for the GC.
// Bad: conversion in loop
var results []string
for _, item := range items {
hash := sha256.Sum256([]byte(item.Data)) // allocation per iteration
results = append(results, fmt.Sprintf("%x", hash))
}
// Better: convert once, or work with []byte from the start
var results []string
for _, item := range items {
hash := sha256.Sum256(item.DataBytes) // if item.DataBytes exists
results = append(results, fmt.Sprintf("%x", hash))
}If your data structure stores strings but you need []byte repeatedly, consider storing the bytes instead—or at least caching the conversion.
In a trading system we optimized, handlers were parsing CSV lines by converting strings to byte slices multiple times. Restructuring to work with byte slices natively dropped allocations from ~500K per second to ~50K.
Check escape analysis with -gcflags=-m
Go's escape analyzer decides whether a variable lives on the stack (fast, no GC pressure) or heap (slow, GC pressure). You can inspect these decisions with the compiler flag -gcflags=-m.
package main
func process(id int) string {
s := "result"
return s
}
func processEscape(id int) string {
s := make([]byte, 100)
return string(s) // escapes: must be heap-allocated
}
func main() {
process(1)
processEscape(1)
}Run with escape analysis:
go run -gcflags=-m main.goYou'll see output like:
./main.go:7:9: "result" does not escape
./main.go:11:9: make([]byte, 100) escapes to heapVariables that "escape" are allocated on the heap and become GC work. The goal is to keep hot-path allocations on the stack. For instance, small, temporary buffers often escape unnecessarily because of how they're used. Using escape analysis, we identified that a JSON marshal helper was escaping a 64-byte buffer; restructuring it to use a sync.Pool instead saved ~40% of allocations in that code path.
Run escape analysis on your hot paths regularly. It's a quick way to catch unexpected heap allocations before they cause latency in production.
Common mistakes
- Assuming preallocation breaks dynamism. If the final size is unknown, estimate conservatively. 20% overallocation is better than 5 reallocations.
- Using sync.Pool for large objects. Pools are best for small, frequently allocated items like buffers. Pooling large objects wastes memory.
- Ignoring interface{} allocations. Passing values as interface{} allocates on the heap. Keep interface{} out of hot loops where possible.
- Not profiling before optimizing. Use
pprofto find the real bottlenecks. Allocation optimizations matter most in high-frequency code paths. - Trusting escape analysis without testing.
-gcflags=-mshows what the analyzer sees, but benchmarks tell you the real impact.
Key takeaways
- Preallocate slices and maps when you know the size; it eliminates reallocation overhead.
- Use
sync.Poolfor short-lived buffers and temporary objects in high-throughput handlers. - Avoid string-to-[]byte conversions in loops; refactor to work with one representation consistently.
- Run
-gcflags=-mon hot-path functions to spot unexpected heap escapes. - Profile your actual service under load; allocation wins matter most in high-RPS scenarios.
- Reducing allocations lowers GC pressure, cuts latency spikes, and improves tail percentiles—the real metrics that matter in production.






Comments
Loading comments…