Garbage Collection and Allocation Pressure in Go
How I think about heap growth, latency, and runtime trade-offs at scale
“We should forget about small efficiencies.”
Donald E. Knuth, “Structured Programming with go to Statements,” ACM Computing Surveys, Vol. 6, №4, ACM, 1974, p. 268.
This is the second article in the series. In the first article, we built a mental model for stack, heap, and escape analysis. That model explained why some values can live inside function lifetime while others need heap lifetime. Now we need to follow the consequence: once objects live on the heap, the runtime must decide when they are still useful and when their memory can be reclaimed.
Go uses garbage collection so developers do not manually free heap objects. That is a major simplification for correctness and maintainability, but it does not make heap memory free. The official Go GC guide is explicit that garbage collection has costs, and it frames those costs around live heap, new heap allocation, CPU overhead, and memory usage. It is the primary source I use for this article: https://go.dev/doc/gc-guide.
The right production question is not whether the garbage collector is good or bad. The question is whether our allocation behavior creates a cost profile that fits the latency, throughput, and memory envelope of the service.
What the garbage collector actually collects
The garbage collector is responsible for heap memory. More precisely, it identifies heap objects that are no longer reachable from the program’s roots and makes their memory reusable. It does not collect values that are still reachable, even if the application will never use them again. Reachability is a runtime property, not an expression of developer intent.
That distinction explains many production memory problems. If a map keeps references to request objects, those objects are live. If a channel buffer holds pointers to large payloads, those payloads are live. If a small slice points into a large backing array, the backing array can remain live. The collector cannot infer that we are “done” with an object when a reachable reference still exists.
This is why Go can have memory leaks even with a garbage collector. The leak is not usually a forgotten free. It is retained memory: objects remain reachable through maps, caches, goroutines, channels, closures, globals, or slices. We will examine those leak patterns in the fourth article. For now, the important point is that the GC only reclaims unreachable heap objects.
Once we understand what can be collected, the next question is what makes collection expensive.
Allocation rate and live heap shape GC cost
The GC has work to do when programs allocate. New heap objects increase the amount of memory the runtime must eventually account for. Live objects increase the amount of memory the collector must trace and preserve. A service that allocates many short-lived objects creates allocation throughput for the runtime to process. A service that retains many objects increases live heap and may increase scanning work.
The Go GC guide describes this cost model in terms of a trade-off between CPU cost and memory cost. A lower heap target generally means the collector runs more often and uses more CPU, but keeps memory usage lower. A higher heap target can reduce GC CPU frequency but allows more memory growth before collection. This is not a moral trade-off; it is an operational one. The reference model is documented in the “Understanding costs,” “GOGC,” and “Memory limit” sections of https://go.dev/doc/gc-guide.
In production, allocation pressure is the combination of allocation rate, object lifetime, and live heap size. A service can allocate frequently without catastrophic latency if those allocations are small, short-lived, and within the service’s CPU budget. Another service can allocate less frequently but retain large structures and create memory pressure at the container or node level.
That means a good memory review does not count allocations in isolation. It asks where allocations happen, how large they are, how long they survive, and whether they occur on critical paths.
Why too many allocations can hurt latency
Allocations are not only about memory size. Allocation rate can translate into CPU work, synchronization pressure inside the allocator, more frequent garbage collection, and cache effects. In latency-sensitive services, those costs may show up as higher tail latency even when average latency looks acceptable.
The safe way to state this is not “allocations are always bad.” Allocations are normal in Go. The Go standard library itself uses heap allocation where it improves API design or correctness. The more accurate claim is: allocations inside hot paths deserve measurement because they can increase runtime work. That position is consistent with the Go diagnostics documentation, which points developers to profiling, tracing, runtime statistics, and pprof when diagnosing performance issues: https://go.dev/doc/diagnostics.
This is also why reducing allocations can improve performance. If a hot path creates fewer heap objects while preserving readability and semantics, the runtime has less heap work to process. But the word “can” matters. A change that reduces one allocation while adding complexity, lock contention, copying, or cache misses may not improve the system.
Get Cássius Paim’s stories in your inbox
Join Medium for free to get updates from this writer.
This is the core discipline: optimize only where the cost is measured, and prefer changes that make lifetime clearer rather than merely clever.
Understanding GOGC without treating it as a magic knob
GOGC controls the trade-off between GC CPU and memory growth. The official Go GC guide explains it as a percentage that defines the target heap growth relative to the live heap after the previous collection, with the default value being GOGC=100: https://go.dev/doc/gc-guide#GOGC. A GC cycle is one complete collection round in which the runtime identifies reachable objects and makes unreachable heap memory available for reuse. Lower GOGC values make the collector run more frequently and keep the heap smaller; higher values allow more heap growth between GC cycles.
A safer way to treat GOGC is as a tuning mechanism for an already understood memory/CPU trade-off. It is not a substitute for fixing avoidable retention, unbounded caches, or allocation-heavy hot paths. Turning GOGC down can reduce peak memory, but it usually increases the amount of CPU spent on garbage collection. Turning it up can reduce GC frequency, but it increases the application's memory footprint. Both moves can be correct in different operating envelopes.
For containerized services, this distinction matters. A service that runs close to its memory limit may need a different strategy than a batch job optimized for throughput. The same application can prefer different GC behavior depending on whether the bottleneck is CPU, latency, memory, or cost.
Once memory limits enter the discussion, GOGC is no longer the only runtime-level control worth knowing.
GOMEMLIMIT and memory-aware operation
Go also supports a soft memory limit through GOMEMLIMIT and the corresponding runtime API. The official GC guide explains that this setting gives the runtime a target for total memory use, rather than making GC decisions only from the heap growth ratio controlled by GOGC: https://go.dev/doc/gc-guide#Memory_limit. The runtime/debug package exposes the same setting programmatically through SetMemoryLimit: https://pkg.go.dev/runtime/debug#SetMemoryLimit.
A useful way to think about GOMEMLIMIT is as an operational guardrail. It is especially useful when a Go process runs inside a container with a fixed memory limit, because it helps the runtime make garbage collection decisions with a concrete memory budget in mind. For example, if a container is limited to 1 GiB, setting GOMEMLIMIT below that value can give the runtime room to react before the process risks being killed by an out-of-memory condition.
But GOMEMLIMIT is not a substitute for application-level memory design. It cannot make an unbounded cache bounded, remove old entries from a map, stop a blocked goroutine, or make retained references disappear. If the application still keeps data reachable through a variable, slice, map, channel, closure, or goroutine, the garbage collector must treat that data as live.
That distinction is important. Runtime knobs can influence when the garbage collector runs and how aggressively it tries to stay within a memory budget, but the code still defines object lifetimes. In Go, memory-aware operation starts in the runtime, but memory discipline still belongs in the application design.
Throughput, latency, and memory are connected decisions
The GC trade-off is usually presented as throughput versus latency, but production systems also care about memory footprint, cloud cost, autoscaling behavior, and failure modes. A lower memory footprint can reduce infrastructure cost but increase CPU. More CPU for GC can affect request latency. A larger heap can improve throughput, but it also increases the risk of container out-of-memory kills when memory limits are tight.
This is why memory management in Go is an architectural concern, not just a micro-optimization concern. It affects API design, data ownership, batch sizes, cache policy, streaming strategy, and concurrency. A handler that reads an entire payload into memory expresses a different memory model than one that streams. A cache with no maximum size expresses a different operational contract than one with eviction.
The garbage collector will enforce reachability rules. It will not choose your service boundaries, cache policy, or data flow. Those decisions remain engineering decisions.
Conclusion
Go’s garbage collector removes manual memory reclamation from application code, but it does not remove the cost of heap lifetime. Heap objects must be traced, live data must be retained, and allocation-heavy hot paths can become CPU and latency problems.
The next article moves from runtime behavior into everyday Go code. We will look at where allocations appear in slices, maps, strings, interfaces, closures, and common library calls, and why measuring matters more than guessing.
💬 Join the Discussion
How does your team reason about GC behavior in production? Do you tune GOGC or GOMEMLIMIT, redesign allocation-heavy paths, enforce cache limits, or rely mostly on pprof after incidents? I would like to hear which trade-offs worked and which ones created surprises.
Originally published on Medium by Cassius Paim, Staff Engineer and Software Architect specializing in Go, Java, and Kotlin
Tags
#Go, #Garbage Collection, #GOGC, #GOMEMLIMIT, #Production Performance







