You deploy a new Go microservice to production. During staging, everything looks perfect. The API responds in under 10 milliseconds. But when production traffic hits 5,000 requests per second, the response times spike. Your p99 latency climbs to 250 milliseconds. You check the CPU usage, and it is sitting at a comfortable 45%. Memory usage is stable.
This scenario is common in Go systems. It is rarely a database bottleneck or a slow network call. The culprit is usually the Go garbage collector (GC) working behind the scenes. Go is designed for low latency (which is why it was chosen for the TypeScript 7.0 native Go compiler), but its runtime makes trade-offs. If your application creates too many objects on the heap, the GC must work harder to clean them up. This work steals CPU cycles from your application and introduces pause times that ruin your latency guarantees (though if you are working in systems languages without a GC, you might instead look at techniques like branchless Rust optimization to save CPU cycles).
How Go's Garbage Collector Works
To control the garbage collector, you have to understand what it does. Go uses a concurrent, tri-color mark-and-sweep collector.
When the GC runs, it goes through several phases:
- Sweep Termination: The runtime prepares for the new GC cycle. It stops the world (STW) briefly.
- Mark Phase: The collector scans the heap to find active objects. It starts from "roots" like global variables and stack pointers. It colors them to keep track of what is in use. This phase runs concurrently with your application code.
- Mark Termination: The collector stops the world again to finish the marking process.
- Sweep Phase: The collector reclaims memory from objects that were not marked. This runs concurrently.
The concurrent nature of this process means your application keeps running while Go marks memory. But this concurrency comes with a cost. The runtime allocates a portion of your CPU capacity to the garbage collector. If your service is already busy handling network requests, losing 25% of your CPU to the GC will cause incoming requests to queue up. This queueing is what drives your p99 latency through the roof.
Tuning GOGC
The oldest tool for controlling this behavior is the GOGC environment variable. By default, GOGC is set to 100.
This number represents a percentage. It tells the runtime how much the heap should grow before triggering another GC cycle. The calculation is straightforward:
TargetHeap = LiveHeap + (LiveHeap * GOGC / 100)
If your application has 50 megabytes of live memory (data that cannot be garbage collected because it is still in use), a GOGC value of 100 means the GC will run when the heap reaches 100 megabytes.
If you set GOGC to 200, the GC will wait until the heap reaches 150 megabytes. If you set it to 50, the GC will run when the heap reaches 75 megabytes.
Adjusting this variable changes the balance between CPU usage and memory usage. A higher GOGC value means the GC runs less often. This saves CPU cycles, but your application uses more memory. A lower GOGC value runs the GC more often. This keeps your memory footprint small, but it consumes more CPU.
If you have extra RAM to spare on your servers, increasing GOGC to 200 or 300 is a quick way to reduce GC overhead and drop your p99 latency. But this approach has a dangerous limit. If your heap grows too large, the operating system will kill your process.
The GOMEMLIMIT Solution
Before Go 1.19, managing memory in containers was difficult. If you set GOGC to 200 to save CPU, a sudden spike in traffic could push your heap size past the container's memory limit. The Kubernetes OOM (Out of Memory) killer would instantly terminate your pod.
Go 1.19 introduced GOMEMLIMIT. This environment variable sets a hard limit on the total memory the Go runtime can use.
When you set GOMEMLIMIT, the runtime monitors its memory usage. If the total memory approaches this limit, the GC runs, even if GOGC says it is not time yet.
This changes how you tune your services. You can set GOGC to a very high value-or even disable it entirely with GOGC=off-and set GOMEMLIMIT to slightly below your container's memory limit.
If your Kubernetes pod has a limit of 1 gigabyte, you might configure your environment like this:
GOMEMLIMIT=900MiB
GOGC=offWith this setup, the GC will only run when memory usage gets close to 900 megabytes. Your application gets to use all available memory to store temporary objects, minimizing GC cycles and saving CPU.
But you must be careful. If your application's live memory requirement exceeds 900 megabytes, the runtime will fall into a state called thrashing. The GC will run continuously, trying to free memory that is still in use. Go has a built-in safeguard that prevents the GC from using more than 50% of the CPU, but your service will still experience severe performance degradation. Always leave a buffer of at least 10% to 20% between GOMEMLIMIT and your hard container limit.
Reducing Allocations
Tuning runtime parameters helps, but it does not fix the root cause. The best way to optimize the garbage collector is to give it less work. You do this by reducing heap allocations.
In Go, variables are allocated either on the stack or the heap. Stack allocations are cheap. When a function returns, its stack memory is reclaimed instantly without any GC involvement. Heap allocations are expensive because they must be tracked and eventually cleaned up by the GC.
You can check where your variables are allocated by running Go's escape analysis tool:
go build -gcflags="-m" ./...Look for output lines like escapes to heap. These are your optimization targets.
One common source of heap allocations is the frequent creation of temporary objects, such as buffers or JSON encoders (if you also work with Python, you can avoid similar serialization bottlenecks by designing resilient Pydantic v2 schemas). You can reuse these objects by using sync.Pool.
Here is a practical example of using sync.Pool to reuse byte buffers:
package main
import (
"bytes"
"sync"
)
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func processRequest(data []byte) {
// Get a buffer from the pool
buf := bufferPool.Get().(*bytes.Buffer)
defer func() {
// Reset the buffer before putting it back
buf.Reset()
bufferPool.Put(buf)
}()
// Use the buffer
buf.Write(data)
// ... perform operations
}By reusing buffers, you prevent the runtime from allocating new memory for every incoming request. This reduces the number of objects the GC has to track.
Another common issue is failing to pre-allocate slices and maps. When you create a slice with var s []int and append elements to it, Go allocates a small underlying array. As you add more elements, Go must allocate a larger array, copy the data, and leave the old array on the heap for the GC to clean up.
If you know the size of your slice beforehand, always initialize it with a capacity:
// Bad: causes multiple heap allocations as the slice grows
var data []int
for i := 0; i < 1000; i++ {
data = append(data, i)
}
// Good: allocates memory once
data := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
data = append(data, i)
}Avoid Pointer-Heavy Structs
The Go garbage collector scans pointers to find live objects. If your heap contains millions of small objects with pointers, the GC has to follow every single pointer. This increases the duration of the mark phase.
You can help the GC by avoiding pointers in your data structures where possible. For example, a slice of structs is much easier for the GC to scan than a slice of pointers to structs.
// Hard for GC to scan (millions of pointers to follow)
type User struct {
ID *int
Name *string
}
var users []*User
// Easy for GC to scan (contiguous block of memory, fewer pointers)
type UserOptimized struct {
ID int
Name string
}
var usersOptimized []UserOptimizedThe Go runtime does not scan slices of pointerless types, such as integers, floats, or structs that contain no pointers. If you store large amounts of data in memory, try to design your data structures to be pointerless. This keeps GC scan times low, even with large heaps.
Profiling and Monitoring
Do not guess where your memory bottlenecks are. Go has built-in profiling tools that show you exactly where allocations happen.
You can enable the net/http/pprof package in your application to expose profiling endpoints. To analyze memory allocations, run the following command while your service is under load:
go tool pprof http://localhost:6060/debug/pprof/allocsThis command opens an interactive shell. Type top to see the functions allocating the most memory, or web to generate a visual call graph.
You can also monitor GC behavior in real-time by reading runtime metrics. The runtime/metrics package provides access to internal statistics. Pay attention to these metrics:
/gc/pauses:seconds: The distribution of STW pause times./memory/classes/heap/objects:bytes: The amount of memory occupied by live objects./gc/cpu/fraction:percent: The percentage of CPU time spent on GC.
If /gc/cpu/fraction:percent is higher than 5%, your service is spending too much time cleaning up memory. You need to reduce allocations or adjust your tuning parameters.
Summary of Optimization Steps
To keep your microservices fast and stable, follow this checklist:
- Measure first: Run
pprofunder load to identify where allocations happen. - Set limits: Configure
GOMEMLIMITto 80% or 90% of your container's memory limit. - Tune GOGC: Increase
GOGCto 200 or more if you have spare memory. - Reuse memory: Use
sync.Poolfor hot paths and frequently allocated structs. - Pre-allocate: Always initialize slices and maps with a capacity when the size is known.
- Simplify structs: Reduce the use of pointers to speed up GC scanning.

