Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 045 / 100

Go garbage collector basics: what developers should know

Understand GC to tune it.

Expona Admin6 min read

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:

go
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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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 with append.
  • 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 pprof to 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.
  • GOGC controls 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 pprof to identify allocation hotspots and escape analysis to understand what lands on the heap.
  • Set GOMEMLIMIT 10–20% below your actual container limit to leave space for off-heap overhead.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles