Introduction
Go's garbage collector is one of the language's hidden superpowers—it keeps memory safe and predictable without the complexity of manual management. Understanding Go garbage collector basics: what developers should know becomes essential once you're running production APIs that handle real traffic, because a GC pause at the wrong moment can spike latency or trigger cascading failures. We've learned this the hard way across e-commerce checkouts, real-time trading systems, and high-throughput CRM backends.
The good news: Go's GC is designed for low latency by default, and you don't need deep internals knowledge to tune it effectively. You need to understand three things: how it works, which knobs to turn, and—most importantly—when allocation habits beat configuration.
Concurrent, low-pause collector
Go uses a concurrent, tri-color mark-sweep garbage collector. That means it runs alongside your application threads most of the time, not stopping the world and freezing everything. The collector marks objects as white (unvisited), gray (visited but not fully scanned), or black (fully scanned and known to be reachable). When collection completes, white objects are swept and freed.
Concurrency keeps pause times down. In production, we've measured typical pause durations in the microseconds to low milliseconds range, even on multi-gigabyte heaps. Compare that to the multi-millisecond or even hundred-millisecond pauses you'd see in Java GC tuning sessions.
The trade-off is complexity: the write barrier (the mechanism that tracks cross-generation references) adds CPU overhead during execution, but it's a deliberate choice for responsiveness. For APIs serving requests, this is almost always the right trade-off.
Let's see what happens in a simple allocation scenario:
package main
import (
"fmt"
"runtime"
)
func main() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Initial heap: %v bytes\n", m.HeapAlloc)
// Allocate 1000 small objects
slice := make([]*int, 1000)
for i := 0; i < 1000; i++ {
x := i
slice[i] = &x
}
runtime.ReadMemStats(&m)
fmt.Printf("After allocation: %v bytes\n", m.HeapAlloc)
// Force GC
runtime.GC()
runtime.ReadMemStats(&m)
fmt.Printf("After GC: %v bytes\n", m.HeapAlloc)
}
Running this shows heap growth and recovery. The GC ran and freed objects that were no longer reachable. Notice we called runtime.GC() manually—in production, the collector decides when to run based on memory pressure and configuration.
GOGC controls frequency
The GOGC environment variable controls how often the garbage collector runs. Its default value is 100, which means: trigger a collection when the heap doubles in size since the last collection. If your heap was 100 MB after a GC, the next one fires when it reaches 200 MB.
Lower GOGC values mean more frequent collections and less peak heap size, but more CPU spent on GC. Higher values mean the heap grows larger, pauses are less frequent, but you risk hitting memory limits suddenly.
In a real e-commerce system handling order surges, we tuned GOGC from the default 100 to 150 during traffic spikes. The heap grew a bit larger, but request latency stayed flatter because we weren't triggering a collection every few seconds. When traffic cooled, the default kicked back in.
You can set it in your shell or in the application:
// Set GOGC at runtime
import "os"
os.Setenv("GOGC", "150")
// Must happen before any allocations that trigger GC
More typically, you'll set it when starting the service:
// In your Dockerfile or startup script
GOGC=150 ./api-server
Monitoring tools like Prometheus can expose heap metrics via runtime.ReadMemStats(), so you can correlate GC frequency with request latency and tune accordingly. The key is: measure first, then adjust.
GOMEMLIMIT sets a soft memory limit
As of Go 1.19, GOMEMLIMIT lets you set a soft limit on the heap size. When the heap approaches this limit, the GC becomes more aggressive, running more frequently and with longer pause times if necessary to stay under the cap.
This is different from GOGC. GOGC says "grow until you double, then collect." GOMEMLIMIT says "never exceed this amount; if you're close, be aggressive about collecting."
In containerized environments, this is critical. Your Kubernetes pod might have a 512 MB memory request and a 1 GB limit. Without GOMEMLIMIT, the GC can let the heap grow unbounded until the OS kills your process. With it, you have predictable behavior:
// Start your API server with
GOMEMLIMIT=800MiB ./api-server
The application will work to stay under 800 MB. If it creeps toward that limit, GC cycles become more aggressive to keep memory in check. If there's a sustained memory leak (genuine bug, not GC behavior), the process will still be killed, but at least the GC isn't wasting CPU trying to manage an impossible situation.
Practical advice: set GOMEMLIMIT to about 80–90% of your actual memory limit. Give the runtime a safety margin for stack frames, caches, and other allocators outside the heap.
Fewer allocations beat tuning
Here's the hard-won lesson from running high-traffic APIs: reducing allocations always outperforms any GC configuration tweak. You cannot tune your way out of a poorly written application.
Every allocation must be traced, marked, and potentially freed. If your request handler creates 100 small objects per call and you handle 10,000 requests per second, that's a million allocations every second. GC tuning cannot fix this.
Consider a naive JSON unmarshaling pattern in a ride-hailing system:
// Bad: allocates new decoder per request
func handleRequest(w http.ResponseWriter, r *http.Request) {
var data map[string]interface{} // Allocates
decoder := json.NewDecoder(r.Body) // Allocates
decoder.Decode(&data) // Allocates
// ... use data
}
This creates allocations on every single request. Better:
// Better: reuse a pool
var decoderPool = sync.Pool{
New: func() interface{} {
return &MyRequest{}
},
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
req := decoderPool.Get().(*MyRequest)
defer decoderPool.Put(req)
json.NewDecoder(r.Body).Decode(req)
// ... use req
}
By pooling allocations and reusing buffers, you eliminate garbage for the GC to collect. This is more effective than any environment variable.
Other allocation-reduction strategies:
- Use preallocated slices:
make([]Item, 0, expectedSize)instead of growing withappend. - Avoid
interface{}; it hides allocations and escape analysis. - Use stack-allocated or embedded structs instead of pointers where possible.
- Batch operations to reduce churn.
- Profile with
go tool pprofto find allocation hotspots.
In a trading system, switching from string concatenation to strings.Builder and reusing buffers cut GC CPU overhead by 40% and dropped p99 latency by 15 milliseconds.
Common mistakes
Mistake 1: Tuning GOGC without measuring. Changing GOGC based on gut feeling wastes time. Always baseline with runtime.ReadMemStats() or a metrics system first. Then adjust and re-measure.
Mistake 2: Setting GOMEMLIMIT too high. If you set it to your actual container limit, there's no buffer for the runtime, and the process might still get OOM-killed. Leave headroom.
Mistake 3: Ignoring escape analysis. Use go build -gcflags="-m" to see what the compiler thinks will escape to the heap. Small adjustments to code structure can keep allocations on the stack.
Mistake 4: Blaming GC for latency spikes without profiling. It might be database queries, network I/O, or lock contention. Profile first with pprof or Datadog. GC rarely surprises once you've instrumented it.
Key takeaways
- Go's concurrent GC keeps pause times low by design; understand the mark-sweep algorithm to avoid assumptions about stop-the-world freezes.
GOGCcontrols collection frequency relative to heap growth; higher values reduce CPU spent on GC, lower values reduce peak heap.GOMEMLIMIT(Go 1.19+) sets a hard ceiling; use it in containerized environments to prevent OOM kills.- Allocation rate matters far more than any tuning parameter; profile your code, pool objects, and preallocate slices.
- Measure baseline metrics with
runtime.ReadMemStats()or Prometheus before changing configuration. - Use
go tool pprofto identify allocation hotspots and escape analysis to understand what lands on the heap. - Set
GOMEMLIMIT10–20% below your actual container limit to leave space for off-heap overhead.






Comments
Loading comments…