Dealing with the Memory Monster
One morning, I noticed one of my Go microservices was consuming 1.5GB of RAM in our staging environment. This service was supposed to be a simple data aggregator, and based on its logic, it shouldn’t have exceeded 200MB. If this reached production, the OOM (Out of Memory) killer would have terminated the process repeatedly, causing downtime.
Go’s garbage collector (GC) is excellent, but it isn’t magic. It cannot save you from logic errors where you accidentally keep references to objects you no longer need. To find these leaks, we need pprof. I have applied this approach in production and the results have been consistently stable, allowing me to slash memory usage by 70% in some cases.
Quick Start: Enabling pprof in 5 Minutes
The easiest way to start profiling a web application is through the net/http/pprof package. It automatically registers handlers that expose profiling data over HTTP.
package main
import (
"fmt"
"log"
"net/http"
_ "net/http/pprof" // Import for side effects
"time"
)
func main() {
// Start a background server for pprof
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Your application logic here
select {}
}
By simply importing _ "net/http/pprof", you expose several endpoints under /debug/pprof/. You can visit http://localhost:6060/debug/pprof/ in your browser to see a list of available profiles, but the real power comes from the command-line tool.
Deep Dive: Understanding the Heap Profile
Memory profiling in Go primarily focuses on the heap. The heap is where objects live when they outlive the scope of a function or are too large for the stack. To capture a heap profile and analyze it, run this command in your terminal:
go tool pprof http://localhost:6060/debug/pprof/heap
Once inside the pprof interactive shell, you have two main ways to look at memory:
- inuse_space: Shows the amount of memory currently held by the application. This is great for finding memory leaks.
- alloc_space: Shows the total amount of memory allocated since the program started, even if it was already garbage collected. This is useful for finding “GC pressure”—code that creates too much garbage, making the CPU work harder.
To switch between them, type sample_index=inuse_space or sample_index=alloc_space inside the pprof prompt.
The “top” command
Type top10 to see the functions consuming the most memory. You’ll see columns like flat (memory used by the function itself) and cum (memory used by the function and everything it calls).
(pprof) top10
Showing nodes accounting for 95MB, 98.2% of 96.74MB total
flat flat% sum% cum cum%
80MB 82.69% 82.69% 80MB 82.69% main.generateData
15MB 15.51% 98.20% 15MB 15.51% runtime.allocm
Advanced Usage: Visualizing the Leak
Reading text tables is fine, but visualizing the call graph makes it much easier to spot the culprit. If you have Graphviz installed, you can generate a web-based UI that is significantly more intuitive.
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
This opens a tab in your browser. My favorite view here is the Flame Graph. In a Flame Graph, the width of each box represents how much memory that function (and its children) is using. If you see a very wide bar that doesn’t seem right, you’ve found your bottleneck.
Spotting a real-world leak
I once encountered a situation where a map[string]*User was growing indefinitely. By using the Peek view in the pprof web UI, I could see that a specific background worker was adding users to this map but never deleting them. In the pprof tool, I used the list command to see exactly which line of code was responsible:
(pprof) list main.WorkerProcess
ROUTINE ======================== main.WorkerProcess in /app/main.go
40MB 40MB (flat, cum) 41.35% of Total
. . 38: func WorkerProcess(u *User) {
40MB 40MB 39: globalCache[u.ID] = u // The leak was here!
. . 40: }
Practical Tips for Memory Optimization
After using pprof to find the issues, you need to fix them. Here are the most effective patterns I use to keep memory usage low.
1. Pre-allocate Slices and Maps
If you know how many items you’ll put in a slice, initialize it with a capacity. This prevents multiple re-allocations and data copying as the slice grows.
// Bad: Frequent allocations
var data []int
for i := 0; i < 1000; i++ {
data = append(data, i)
}
// Good: One allocation
data := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
data = append(data, i)
}
2. Use sync.Pool for Frequent Allocations
If your application frequently creates and destroys the same type of object (like a JSON buffer or a temporary struct), use sync.Pool. This allows the GC to reuse memory instead of constantly freeing and re-allocating it.
3. Be Careful with Slices of Slices
A common mistake is taking a small sub-slice from a very large slice. The small slice still holds a reference to the large underlying array, preventing the large array from being garbage collected. To fix this, copy the data to a new, smaller slice.
// Potential leak: smallPart keeps the whole bigData in memory
bigData := make([]byte, 100*1024*1024) // 100MB
smallPart := bigData[:10]
// Solution: Copy the data
smallPart := make([]byte, 10)
copy(smallPart, bigData[:10])
4. Watch out for Goroutine Leaks
Every goroutine takes a minimum of 2KB of stack memory. If you start goroutines that never finish (e.g., waiting on a channel that never closes), you will eventually run out of memory. Use pprof/goroutine to check if your goroutine count keeps climbing over time.
Optimizing memory isn’t about micro-optimizing every line; it’s about identifying the 20% of code causing 80% of the pressure. By integrating pprof into your workflow, you move from guessing to knowing exactly where your bytes are going.

