Go’s garbage collector is surprisingly good at not being a bottleneck.

Let’s see it in action. Imagine a simple web server that processes incoming requests by doing a bit of work and then responding.

package main

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

func handler(w http.ResponseWriter, r *http.Request) {
	// Simulate some work
	time.Sleep(10 * time.Millisecond)
	fmt.Fprintf(w, "Hello, %s!\n", r.URL.Path[1:])
}

func main() {
	http.HandleFunc("/", handler)
	log.Println("Starting server on :8080")

	// Start a background goroutine to print GC stats periodically
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		ticker := time.NewTicker(5 * time.Second)
		defer ticker.Stop()
		for range ticker.C {
			var m runtime.MemStats
			runtime.ReadMemStats(&m)
			fmt.Printf("HeapAlloc = %v MiB", bToMb(m.HeapAlloc))
			fmt.Printf("\tNumGC = %v\n", m.NumGC)
		}
	}()

	err := http.ListenAndServe(":8080", nil)
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
	wg.Wait()
}

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

If you run this and hit http://localhost:8080/test repeatedly, you’ll see output like this in your terminal:

2023/10/27 10:00:00 Starting server on :8080
HeapAlloc = 5 MiB	NumGC = 0
HeapAlloc = 5 MiB	NumGC = 0
HeapAlloc = 6 MiB	NumGC = 0
HeapAlloc = 6 MiB	NumGC = 0
HeapAlloc = 7 MiB	NumGC = 0
HeapAlloc = 7 MiB	NumGC = 0
HeapAlloc = 8 MiB	NumGC = 1 // <-- GC happened!
HeapAlloc = 8 MiB	NumGC = 1
HeapAlloc = 8 MiB	NumGC = 1
HeapAlloc = 9 MiB	NumGC = 1

Notice how NumGC increments only occasionally, and HeapAlloc (memory in use) doesn’t grow unbounded. This is the garbage collector doing its job efficiently, cleaning up memory that’s no longer referenced without you having to manually free it.

The core problem Go solves is manual memory management. In languages like C or C++, you’re responsible for allocating memory when you need it and deallocating it when you’re done. Forget to deallocate, and you get a memory leak. Deallocate too early, and you get a dangling pointer and a crash. Go’s garbage collector automates this, freeing developers to focus on application logic.

Internally, Go uses a concurrent, tri-color mark-and-sweep garbage collector. When the GC runs, it starts from known roots (like global variables and stack variables of active goroutines), marks all reachable objects, and then sweeps away everything that wasn’t marked. "Concurrent" means it does most of its work while your application is still running, minimizing pauses.

The primary lever you have is understanding your application’s memory allocation patterns and how they interact with the GC. While the GC is smart, excessive allocations, especially short-lived ones, can put pressure on it.

The Go runtime exposes several metrics you can use to understand GC behavior. runtime.ReadMemStats is your entry point. Key fields include:

  • Alloc: Bytes of allocated heap objects.
  • TotalAlloc: Cumulative bytes allocated for heap objects. This is a good indicator of allocation rate.
  • HeapAlloc: Bytes of heap objects in use.
  • NumGC: The number of garbage collection cycles.
  • PauseNs: A slice containing the duration of the last N GC pauses.

You can also use the pprof tool, which is invaluable for profiling. To enable it, you’d typically add this to your main function:

import _ "net/http/pprof"

Then, when your server is running, you can access profiling data via http://localhost:8080/debug/pprof/. For memory profiling, you’d use http://localhost:8080/debug/pprof/heap.

The go tool pprof command-line utility can then analyze this data. For example, to analyze heap allocations over the last 30 seconds:

go tool pprof http://localhost:8080/debug/pprof/heap

This will drop you into an interactive prompt where you can type commands like top to see the functions allocating the most memory, list <function_name> to see source code with allocation annotations, and web to generate a call graph visualization.

A common optimization pattern involves reducing the number of allocations. For instance, reusing buffers instead of creating new ones for every request can significantly lower GC pressure. Consider using sync.Pool for this.

var bufPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 1024) // Example buffer size
    },
}

func processRequest(r *http.Request) {
    buf := bufPool.Get().([]byte) // Get a buffer from the pool
    defer bufPool.Put(buf)        // Return it when done

    // Use buf for processing...
    // ...
}

This sync.Pool approach is powerful because it doesn’t guarantee reuse (the GC can still reclaim pooled objects if the program is idle for a long time), but it makes them available for reuse by the same goroutine or other goroutines, drastically cutting down on heap churn for frequently allocated objects.

If you find yourself consistently seeing high GC pause times or a high NumGC count in your MemStats, it’s a strong signal that your allocation rate is too high. Focus on identifying hot paths that allocate heavily and see if you can reuse objects or use more efficient data structures.

The most surprising thing about Go’s GC is how little you often need to tune it. Most performance issues attributed to GC are actually allocation problems.

When you start optimizing, you’ll often encounter goroutine leaks.

Want structured learning?

Take the full Performance course →