Garbage collection isn’t some magical background process; it’s a deliberate, often aggressive, act of reclamation that fundamentally alters your application’s runtime behavior.

Let’s see what happens when a Go application’s garbage collector needs to do its thing. Imagine a web server handling requests.

package main

import (
	"fmt"
	"net/http"
	"runtime"
	"time"
)

var data []byte

func handler(w http.ResponseWriter, r *http.Request) {
	// Simulate some work and memory allocation
	tempData := make([]byte, 1024*1024) // Allocate 1MB
	for i := range tempData {
		tempData[i] = byte(i % 256)
	}
	data = tempData // Keep a reference to prevent immediate GC
	time.Sleep(50 * time.Millisecond) // Simulate processing time
	fmt.Fprintf(w, "Processed request. Memory allocated: %d bytes\n", len(tempData))
}

func main() {
	http.HandleFunc("/", handler)
	fmt.Println("Server started on :8080")
	// Print GC stats periodically
	go func() {
		for {
			var m runtime.MemStats
			runtime.ReadMemStats(&m)
			fmt.Printf("Alloc = %v MiB, TotalAlloc = %v MiB, Sys = %v MiB, NumGC = %v\n",
				bToMb(m.Alloc), bToMb(m.TotalAlloc), bToMb(m.Sys), m.NumGC)
			time.Sleep(5 * time.Second)
		}
	}()
	http.ListenAndServe(":8080", nil)
}

func bToMb(b uint64) uint64 {
	return b / 1024 / 1024
}

If you run this and hit http://localhost:8080 repeatedly, you’ll see memory usage climb and then periodically drop. The NumGC counter will increment, and the Alloc (currently in use) and Sys (total memory acquired from OS) metrics will fluctuate. The time.Sleep(50 * time.Millisecond) simulates work, but the GC pauses, however brief, are the real latency drivers.

The core problem GC solves is memory leaks. Without it, programs would continuously allocate memory, never releasing it, and eventually crash or become unusable. GC automates this release. The trade-off is performance: finding and reclaiming unused memory requires CPU cycles and can introduce pauses in application execution.

JVM’s GC is a beast of configurability. The -Xmx and -Xms flags set the maximum and initial heap size, respectively. For low-latency applications, you’ll often see -Xms set equal to -Xmx to prevent heap resizing pauses. The -XX:+UseG1GC flag selects the Garbage-First collector, which aims to meet pause time goals by dividing the heap into regions and prioritizing collection in regions with the most garbage. Tuning G1 involves parameters like -XX:MaxGCPauseMillis (a target pause time) and -XX:G1HeapRegionSize (which can impact fragmentation and collection efficiency).

Node.js, built on V8, uses a generational, incremental garbage collector. It distinguishes between "young" and "old" generations. Objects are created in the young generation and promoted to the old generation if they survive a few collections. This is efficient because most objects die young. The NODE_OPTIONS='--max-old-space-size=4096' flag is crucial for controlling the maximum size of the old generation heap, directly impacting when a full, potentially longer, collection might occur.

Go’s garbage collector is a concurrent, tri-color mark-and-sweep collector. "Concurrent" means it does most of its work alongside the application goroutines, minimizing stop-the-world pauses. "Tri-color" refers to the marking phases (white: un-scanned, gray: scanned but neighbors not yet scanned, black: scanned and all neighbors scanned). The GOGC environment variable controls the heap growth factor; GOGC=100 means the GC will run when the heap is twice the size of the live heap. Increasing GOGC (e.g., GOGC=200) postpones GC cycles, potentially increasing memory usage but reducing GC frequency and associated pauses.

The "stop-the-world" (STW) pause is the most notorious latency contributor. Even concurrent collectors have brief STW phases for synchronization or final marking. The goal is to minimize their duration and frequency. For JVM, using low-pause collectors like G1 or ZGC (-XX:+UseZGC) is key. ZGC is a scalable, low-latency collector designed for applications that require pauses under 10ms, even with heaps up to terabytes. For Node.js, managing --max-old-space-size and optimizing object allocation patterns can prevent excessive STW pauses. Go’s concurrent GC is generally good, but understanding GOGC and avoiding excessive allocations in critical paths is important.

What many overlook is the impact of object allocation patterns on GC. In JVM, excessive creation of short-lived objects can flood the young generation, leading to frequent minor collections, which are fast but numerous. In Go, allocating large objects can sometimes force more work on the collector. In Node.js, repeated creation of large objects within tight loops can pressure the old generation. The GC is a reactive system; its efficiency is heavily influenced by how proactively your application manages its memory lifecycle.

The next challenge is managing the interaction between GC and application threads, particularly when dealing with shared mutable state.

Want structured learning?

Take the full Performance course →