Common Causes of Memory Leaks

Goroutines, Slices, Maps, Timers

Identify and fix Go memory leaks caused by blocked goroutines, unbounded slices/maps, and unstopped timers using pprof and proper resource cleanup.

The silent climb

You deploy a Go service. The first day is smooth. Memory usage sits flat at 50 megabytes. The garbage collector runs every few seconds, reclaiming temporary allocations. By day thirty, the memory graph looks like a staircase. Each step is a small jump. The garbage collector runs, but the baseline never drops. Eventually, the process hits the container limit. The kernel kills it. You didn't leak memory in the C sense where you forgot to call free. You just forgot to let go.

Go has a garbage collector. The collector reclaims memory only when nothing points to it. A memory leak in Go is a reference that survives longer than intended. The collector sees the reference and assumes the program needs the data. The memory stays allocated. The leak is the reference, not the allocation.

Think of the garbage collector as a janitor cleaning a room. The janitor removes anything that is not being held. If someone holds a string attached to a box of trash, the janitor leaves the box. The trash accumulates. The room fills up. The leak is the string.

Goroutines that never return

Goroutines are lightweight threads managed by the Go runtime. Each goroutine starts with a small stack, usually 2 kilobytes. The stack grows if the goroutine needs more space. If a goroutine blocks forever, its stack stays in memory. One blocked goroutine costs almost nothing. Ten thousand blocked goroutines cost megabytes. If your service spawns goroutines faster than they finish, you leak stacks.

The most common cause is a goroutine waiting on a channel that never sends a value.

package main

import "fmt"

// LeakGoroutine starts a worker that waits on a channel forever.
func LeakGoroutine() {
	ch := make(chan int)
	// This goroutine blocks here. It waits for a value on ch.
	// No code ever sends to ch, so this goroutine never exits.
	go func() {
		val := <-ch
		fmt.Println(val)
	}()
	// The main function returns. The goroutine is still running in the background.
	// The runtime keeps the goroutine alive. The stack memory is leaked.
}

The goroutine calls <-ch. The runtime suspends the goroutine and puts it on a wait queue. The channel ch is unbuffered. A send operation is required to wake the receiver. If no send happens, the goroutine waits indefinitely. The stack frame, the channel reference, and the closure environment all stay in memory.

A select statement can hide the same problem. If every case in a select blocks, the goroutine blocks.

package main

import "time"

// BlockSelect demonstrates a select that blocks forever.
func BlockSelect() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	go func() {
		// Both channels are empty and unbuffered.
		// No code sends to ch1 or ch2.
		// The select statement blocks forever.
		select {
		case <-ch1:
		case <-ch2:
		}
	}()
	// The goroutine is stuck. Memory is leaked.
}

The fix is usually a cancellation signal. The Go community convention is to use context.Context for this. A context carries a deadline or a cancellation channel. When the context is done, the goroutine checks the context and exits.

package main

import (
	"context"
	"fmt"
)

// SafeWorker uses a context to stop the goroutine.
func SafeWorker(ctx context.Context) {
	ch := make(chan int)
	go func() {
		// The select checks both the data channel and the context.
		// If the context is cancelled, the goroutine exits.
		select {
		case val := <-ch:
			fmt.Println(val)
		case <-ctx.Done():
			// Context cancelled. Exit cleanly.
			fmt.Println("worker stopped")
		}
	}()
	// Later, cancel the context to release the goroutine.
}

Functions that take a context should respect cancellation and deadlines. The context always goes as the first parameter, conventionally named ctx. If you pass a context through a call chain, every long-lived operation must check ctx.Done(). A goroutine that ignores cancellation is a leak waiting to happen.

Slices that grow forever

Slices are descriptors. A slice value contains a pointer to an underlying array, a length, and a capacity. The array lives on the heap. If you append to a slice, the runtime might allocate a larger array and copy the data. The old array becomes garbage and gets collected.

The leak happens when a slice is part of a long-lived object. If the object stays alive, the slice stays alive. If the slice keeps growing, the underlying array keeps growing. The array never shrinks.

package main

import "fmt"

// Logger holds a slice of log entries.
type Logger struct {
	entries []string
}

// NewLogger creates a logger.
func NewLogger() *Logger {
	return &Logger{
		entries: make([]string, 0, 10),
	}
}

// Log appends a message to the logger.
func (l *Logger) Log(msg string) {
	// Append adds to the slice.
	// If capacity is exceeded, a new larger array is allocated.
	l.entries = append(l.entries, msg)
}

// PrintLogs prints all entries.
func (l *Logger) PrintLogs() {
	fmt.Println(l.entries)
}

If you create a Logger and call Log thousands of times, the entries slice grows. The underlying array might reach megabytes. The Logger struct is likely stored in a global variable or a long-lived server handler. The array is pinned by the slice header inside the struct. The garbage collector cannot free the array because the struct points to it.

Setting the slice length to zero does not free the memory.

// ClearLogs resets the length but keeps the capacity.
func (l *Logger) ClearLogs() {
	// This sets len to 0. The underlying array is still allocated.
	// The capacity remains high. Memory is not released.
	l.entries = l.entries[:0]
}

The capacity is preserved so that future appends can reuse the array. This is efficient for temporary buffers. It is a leak for long-lived accumulators. To release the memory, you must replace the slice with a new one or set it to nil.

// ReleaseLogs frees the underlying array.
func (l *Logger) ReleaseLogs() {
	// Setting to nil releases the reference to the array.
	// The GC can reclaim the memory.
	l.entries = nil
}

Slices grow. They never shrink on their own. If you need a bounded log, use a fixed-size array or a ring buffer. If you use a slice, implement rotation. When the slice reaches a limit, write the data to disk and reset the slice to nil.

Maps that never evict

Maps are hash tables. They store key-value pairs. When you add a key, the map might resize the underlying table to maintain performance. If you add keys and never remove them, the map grows forever.

Maps are often used as caches. A cache stores results to avoid expensive computation. If the cache grows without bounds, it consumes all available memory.

package main

import "fmt"

// Cache stores computed values.
type Cache struct {
	data map[string]string
}

// NewCache creates a cache.
func NewCache() *Cache {
	return &Cache{
		data: make(map[string]string),
	}
}

// Get retrieves a value.
func (c *Cache) Get(key string) (string, bool) {
	val, ok := c.data[key]
	return val, ok
}

// Set adds a value.
func (c *Cache) Set(key string, val string) {
	// Adding a key expands the map if needed.
	c.data[key] = val
}

If your service receives requests with unique keys, the map grows with every request. The keys are strings. The values are strings. The map structure itself has overhead. The memory usage climbs linearly with the number of unique keys.

Deleting a key marks the entry as empty. It does not shrink the map capacity. The map runtime keeps the table size to avoid frequent resizing. If you delete and add keys repeatedly, the map stays large.

// Delete removes a key.
func (c *Cache) Delete(key string) {
	// Delete marks the entry as empty.
	// The map capacity does not shrink.
	delete(c.data, key)
}

Maps are caches. Caches need eviction policies. You must decide when to remove entries. Common strategies include time-to-live (TTL), where entries expire after a duration, or least-recently-used (LRU), where the oldest entries are dropped when the cache is full. Without eviction, a map is just a memory leak with a hash function.

Timers that keep ticking

The time package provides timers. A timer fires once after a duration. You create a timer with time.NewTimer. The timer returns a *Timer struct. The struct contains a channel that receives the current time when the timer fires.

If you create a timer and discard the variable without stopping it, the internal machinery keeps running. The timer uses a background goroutine and a heap structure in the runtime. If you don't stop the timer, the runtime keeps the timer entry alive.

package main

import (
	"fmt"
	"time"
)

// LeakTimer creates a timer and forgets to stop it.
func LeakTimer() {
	// Create a timer that fires in one hour.
	t := time.NewTimer(time.Hour)
	// The timer is running. The runtime tracks it.
	// If the function returns, t goes out of scope.
	// The timer is not stopped. The internal goroutine/channel stays alive.
}

The timer fires after one hour. The channel sends a value. If no one reads the channel, the send blocks. The timer goroutine blocks. The memory is leaked. Even if you read the channel, the timer object stays in the runtime's heap until it fires.

Always call Stop() when the timer is no longer needed.

// SafeTimer stops the timer before exiting.
func SafeTimer() {
	t := time.NewTimer(time.Hour)
	// Do some work.
	// If the work finishes early, stop the timer.
	t.Stop()
	// The runtime removes the timer. Memory is reclaimed.
}

If you need a one-shot callback, use time.AfterFunc. It runs a function when the timer fires and handles cleanup automatically.

// AfterFuncExample uses AfterFunc for a one-shot callback.
func AfterFuncExample() {
	// AfterFunc runs the function after the duration.
	// It returns a Timer that can be stopped if needed.
	// If you don't need to stop it, you can ignore the return value.
	_ = time.AfterFunc(time.Second, func() {
		fmt.Println("fired")
	})
}

Timers are resources. Stop them or they stop you.

Pitfalls and debugging

The compiler does not catch memory leaks. The compiler checks types and syntax. It does not track runtime behavior. A leak compiles cleanly. The leak shows up as rising memory usage in your metrics.

If you suspect a leak, use the profiler. The net/http/pprof package exposes profiling endpoints. You can query the heap profile to see which allocations are growing.

go tool pprof -top http://localhost:6060/debug/pprof/heap

The output lists functions by memory usage. Look for functions that allocate memory and are called frequently. If a function allocates memory that is never freed, it appears at the top of the list.

Goroutine leaks show up in the goroutine profile.

go tool pprof -top http://localhost:6060/debug/pprof/goroutine

This lists goroutines by stack trace. If you see thousands of goroutines stuck on a channel receive, you have a goroutine leak.

Common runtime errors can hint at leaks. If you forget to import a package, the compiler rejects the program with undefined: pkg. If you import a package and don't use it, you get imported and not used. These are compile-time errors. Leaks are runtime issues.

A common panic is runtime error: all goroutines are asleep - deadlock!. This happens when the main goroutine exits while other goroutines are blocked. The runtime detects that no progress is possible. This is not a leak; it's a crash. A leak is silent. The program runs, but memory grows.

Convention aside: gofmt is mandatory. Don't argue about indentation. Let the tool decide. Most editors run it on save. Consistent formatting makes code easier to read. When you read code to find a leak, clear formatting helps you spot the logic error.

Another convention: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. When debugging a leak, check error returns. A failed operation might leave a resource open.

Decision matrix

Use a buffered channel when you need to decouple producers and consumers without blocking. A buffer allows sends to succeed even if no receiver is ready. This prevents goroutines from blocking on sends.

Use time.AfterFunc when you need a one-shot callback and want to avoid manual cleanup. It runs a function and handles the timer lifecycle.

Use a fixed-size array or a ring buffer when you need bounded memory for a log or queue. Arrays have a fixed capacity. They cannot grow. This prevents unbounded memory usage.

Use context.WithTimeout when you need to cancel a goroutine after a deadline. The context signals cancellation. The goroutine checks the context and exits.

Use delete on a map when a key is no longer relevant. Removing keys prevents the map from growing indefinitely. Implement an eviction policy to keep the map size bounded.

Use slice = nil when you need to release the underlying array of a long-lived slice. Setting the slice to nil drops the reference. The garbage collector reclaims the memory.

Use a worker pool when you need bounded concurrency to protect a downstream service. A pool limits the number of active goroutines. This prevents spawning too many goroutines and leaking stacks.

Use plain sequential code when you don't need concurrency. The simplest thing that works is usually the right thing. Concurrency adds complexity. Complexity hides leaks.

Where to go next