Reduce GC pressure

GODEBUG settings control runtime feature compatibility and cannot be used to reduce garbage collection pressure or optimize memory usage.

The latency spike that isn't CPU

Your API handles 100 requests per second with 20ms latency. Traffic doubles. Latency jumps to 150ms. CPU usage stays flat. The garbage collector is running every 50ms instead of every 2 seconds. The GC isn't broken. Your code allocates too much memory, too often.

Go's garbage collector is concurrent and generational. It runs while your program runs, marking live objects and sweeping dead ones. It pauses the world briefly for mark termination. If you allocate memory faster than the GC can clean it, the collector runs more frequently. Those pauses add up. You see latency spikes, timeouts, and jitter.

You cannot fix this with a flag. GODEBUG settings control runtime behavior compatibility, not allocation strategy. Toggling http2client=0 or panicnil=1 changes feature behavior. It does not optimize the collector or reduce allocations. Setting GOGC changes when the GC triggers, but it doesn't reduce the work. It just delays the cleanup or makes pauses longer. The only way to reduce GC pressure is to allocate less memory or reuse objects.

GC pressure is a design problem, not a configuration problem.

GC pressure is allocation behavior

Every time your code allocates memory that lives on the heap, you add work for the garbage collector. The allocator is fast. Go uses a bump-pointer allocator for small objects, which is essentially moving a counter. The cost comes later. The GC must scan those objects to determine if they are still reachable. If you allocate millions of short-lived objects, the GC spends most of its time scanning and freeing them.

Think of the GC as a janitor in a busy office. If everyone throws away a coffee cup every minute, the janitor is constantly walking around with a bag. If everyone uses a reusable mug, the janitor only comes by once a day. The janitor is efficient, but you still want to minimize the interruptions.

Reducing GC pressure means reducing the rate of heap allocations. You do this by keeping data on the stack, reusing buffers, or choosing types that allocate less.

Escape analysis decides the cost

The Go compiler runs escape analysis during compilation. It determines whether each variable lives on the stack or the heap. Stack allocation is cheap. The memory is reclaimed when the function returns. Heap allocation is expensive for the GC. The memory must be tracked and eventually freed.

A variable escapes to the heap if:

  • Its address is taken and stored in a global variable or returned.
  • It is passed to a function that might store it.
  • It is larger than the stack frame limit.
  • It is used in a goroutine that outlives the current function.

You can see escape analysis results by running go build -gcflags="-m". The output lists every variable and whether it escapes.

# output:
./main.go:10:6: moved to heap: buf
./main.go:12:14: &buf escapes to heap

If you see variables escaping that should stay on the stack, you can refactor the code. Return values instead of pointers. Split large functions. Avoid taking addresses of local variables unless necessary.

The compiler rejects this with an undefined-variable error if you reference a variable that doesn't exist, but escape analysis warnings are informational. You must read them to understand allocation behavior.

Escape analysis is your first line of defense. Check it before you reach for pooling.

Reuse with sync.Pool

When you must allocate heap objects frequently, sync.Pool lets you reuse them. A pool stores objects that can be retrieved and returned. It is ideal for request-scoped buffers, structs, or slices that are allocated and discarded in tight loops.

sync.Pool is per-processor. Each processor has a local cache. Retrieval and return are lock-free for the local cache. This makes pooling extremely fast.

Here is a pool for byte buffers used in an HTTP handler.

var bufferPool = &sync.Pool{
    // New is called only when the pool is empty.
    // It allocates a new buffer with initial capacity.
    New: func() interface{} {
        return make([]byte, 0, 1024)
    },
}

func handleRequest(w http.ResponseWriter, r *http.Request) {
    // Get retrieves a buffer from the pool.
    // It returns nil only if New is not set and pool is empty.
    buf := bufferPool.Get().([]byte)
    
    // Reset the length to zero but keep the capacity.
    // This reuses the underlying array without allocation.
    buf = buf[:0]
    
    // Process the request using buf.
    // Append data to buf as needed.
    buf = append(buf, "response: "...)
    
    // Write the response.
    w.Write(buf)
    
    // Return the buffer to the pool for reuse.
    // The capacity is preserved for the next user.
    bufferPool.Put(buf)
}

The New function allocates a fresh object only when the pool is empty. Get retrieves an object. Put returns it. You must reset the object's state before putting it back. If you return a buffer with data, the next user sees stale data.

Convention aside: receiver names should be one or two letters matching the type. If you add a method to a pooled type, use (b *Buffer), not (this *Buffer). This keeps code concise and idiomatic.

The pool is a cache, not a vault. The GC can clear it anytime.

Pitfalls and runtime traps

sync.Pool is powerful but has quirks. Understanding them prevents subtle bugs.

The pool is cleared by the garbage collector. When the GC runs, it empties all pools. This is intentional. It prevents pools from holding onto memory that is no longer needed. If your application has high GC pressure, the pool might be empty often. The New function runs frequently, and you get no reuse benefit. Pooling helps when GC pressure is moderate and objects are reused quickly.

If you return a pointer to a pooled object to a caller, the caller might access memory that gets reused. This leads to data corruption. The compiler won't catch this. You must audit the lifetime manually. Ensure pooled objects are used only within the scope that retrieves them.

Type assertions on Get can panic. The pool stores interface{}. If you put a []byte in the pool but assert to []int, the program panics with interface conversion: interface {} is []byte, not []int. Always assert to the correct type.

Goroutine leaks happen when a goroutine waits on a channel that never gets closed. If you use a pool to manage goroutines, ensure you have a cancellation path. Returning a goroutine to a pool is rare and usually a bad idea. Pool data, not execution.

Don't pass a *string. Strings are already cheap to pass by value. They are immutable and consist of a pointer and length. Passing a pointer adds an indirection without saving memory. Use string directly.

The worst pool bug is the one that never logs. Stale data in a reused object manifests as random corruption. Add assertions in tests to verify object state after reuse.

When to optimize

Not every allocation needs optimization. Premature optimization wastes time. Focus on hot paths and large objects.

Use strings.Builder when building strings from multiple parts. It allocates once and grows internally.

Use sync.Pool when allocating large objects frequently in a concurrent workload.

Use stack allocation when the object doesn't escape. Check with -gcflags="-m".

Use smaller types when you can represent data with less memory. A uint32 uses less space than int64 on 64-bit systems.

Use GODEBUG=gctrace=1 when diagnosing GC frequency. It prints GC cycles to stderr. Look at the gc line to see CPU time and pause duration. Use this to measure improvement, not to fix the problem.

Use plain sequential code when you don't need concurrency. The simplest thing that works is usually the right thing.

GC pressure is allocation behavior. Measure, refactor, and reuse.

Where to go next