The invisible tax on your heap
You write a service that handles thousands of requests per second. The CPU usage stays flat. Memory climbs slowly, then drops sharply every few seconds. Your latency spikes in perfect sync with those drops. The garbage collector is working, but it is working too hard. You need to see what it is doing before it starts throttling your application.
What the collector is actually doing
Go uses a concurrent, tri-color mark-and-sweep garbage collector. It runs alongside your program, not instead of it. Think of it like a janitorial crew that cleans a busy restaurant while it is still serving customers. They mark dirty tables, sweep them during lulls, and occasionally pause the whole room for a quick reset. The goal is to keep pause times under a millisecond. When the heap grows fast or objects live too long, the crew has to work overtime.
Monitoring GC performance means tracking three things. How often the collector runs. How long it pauses your goroutines. How much memory it allocates versus how much it reclaims. The runtime tracks all of this automatically. You just need to know where to look.
The GC does not steal your CPU. It shares it. Track the sharing, not the stealing.
The quick way: GODEBUG
The fastest way to see collector activity is through an environment variable. Go ships with a built-in debug flag that prints statistics to standard error every time a cycle completes. You do not need to modify your code. You just set the variable before the process starts.
Here is the simplest way to enable GC tracing:
# Enable GC tracing for the current shell session
export GODEBUG=gctrace=1
# Run your application with the flag active
go run main.go
When the program runs, you will see a line of numbers printed to stderr after every GC cycle. The output looks dense, but each column tells a specific story. The first column is the cycle number. The STW column shows the stop-the-world pause in microseconds. This is the time your goroutines were frozen while the collector synchronized its state. The GC column shows how long the mark phase took. The heap-alloc column tracks how many bytes your program allocated since the last cycle. The heap-live column shows how many bytes are currently in use. The heap-goal column tells you the target heap size that triggered this collection.
You can use these numbers to spot patterns. If STW consistently exceeds 500 microseconds, your pause budget is bleeding into your latency SLA. If heap-alloc is massive but heap-live stays low, you are churning through short-lived objects and the collector is doing its job. If heap-live climbs steadily across cycles, you have a memory leak or objects are living longer than expected.
gctrace is a diagnostic mirror. Look at it, learn from it, then turn it off.
The programmatic way: runtime/metrics
Environment variables work for local debugging. Production systems need structured data. The runtime/metrics package exposes the same counters as a slice of runtime.Metric. You query it, parse the results, and feed them to your observability stack. This approach replaces the older runtime.ReadMemStats function for most use cases because it is faster, more extensible, and avoids allocating temporary structures.
Here is a minimal function that reads GC pause times and cycle counts:
package main
import (
"fmt"
"runtime/metrics"
)
// FetchGCStats returns the total GC cycles and cumulative pause duration.
func FetchGCStats() (uint64, float64) {
// Define the exact metrics we want to sample
samples := []metrics.Sample{
{Name: "gc/cycles/total"},
{Name: "gc/pauses/seconds"},
}
// Read the current values from the runtime
values := metrics.Read(samples)
// Extract the cycle count from the first sample
cycles := values[0].Uint64()
// Extract the pause duration from the second sample
pauses := float64(values[1].Float64())
return cycles, pauses
}
The metrics.Sample struct takes a string path. These paths follow a consistent naming convention: category/subcategory/unit. The runtime validates the paths at call time. If you pass a typo, the Read function returns a sample with a Kind of metrics.KindBad and a Value that holds an error string. You should always check the Kind field in production code to avoid silent failures.
The function returns raw numbers. You still need to decide how often to call it. Reading metrics is cheap, but calling it in a tight loop without a timer will spike your own CPU usage. The runtime updates these counters atomically, but the Go scheduler still needs to yield time to your sampling goroutine.
Metrics are cheap until you read them in a tight loop. Sample deliberately.
When the numbers lie
GC metrics tell you what happened. They do not tell you why. A high pause time does not automatically mean your code is slow. It might mean the collector is struggling with a fragmented heap, or that your program is allocating in tight loops, or that you are holding references to large slices longer than necessary.
If you try to parse gctrace output in production, you will waste CPU cycles on string splitting and regex matching. The format is not guaranteed to stay stable across minor versions. The compiler will not stop you, but your monitoring pipeline will fill with malformed data. Export structured data instead.
If you are on Go 1.18 or earlier, the runtime/metrics package does not exist. The compiler rejects the import with an undefined: runtime/metrics error. You can fall back to runtime.ReadMemStats, but it allocates a temporary MemStats struct on every call and is slower than the modern API. Upgrade if you can.
When you build a background sampler, follow the standard Go convention for long-running tasks. Pass a context.Context as the first parameter, name it ctx, and respect cancellation. The context should control the ticker lifecycle so the goroutine exits cleanly when the service shuts down. Public functions start with a capital letter. Private helpers start lowercase. The compiler enforces visibility through naming, not keywords.
Parsing gctrace in production is a performance trap. Export structured data instead.
Picking your monitoring strategy
Use GODEBUG=gctrace=1 when you are debugging a local reproduction and need immediate visibility into pause times and heap growth. Use runtime/metrics when you are building a production service and need structured, parseable data for Prometheus or Datadog. Use runtime.ReadMemStats when you are stuck on Go 1.18 or earlier and need a quick snapshot of heap allocation. Use manual allocation tracking when you are writing a memory allocator or a custom pool and need byte-level precision.
Choose the tool that matches your environment. Debug locally, measure remotely.