Skip to content

CWE-401: Missing Release of Memory after Effective Lifetime - Go

Overview

Memory leaks in Go typically involve goroutine leaks (blocked goroutines that never exit), unclosed resources (files, connections, HTTP response bodies), and channels that prevent garbage collection. Go's garbage collector reclaims memory, but not a file descriptor, a network connection or a goroutine that is still blocked: those need explicit cleanup with defer, and an exit path for every goroutine.

Primary Defence: defer the close immediately after acquiring a file, a connection or an HTTP response body, so every return path releases it. Give every goroutine you spawn a way out - a context it selects on, a channel close, or a timeout - so a blocked operation cannot strand it.

Common Vulnerable Patterns

Unclosed HTTP Response Bodies

// VULNERABLE - Unclosed HTTP Response Bodies
func fetchData(url string) ([]byte, error) {
    resp, err := http.Get(url)
    if err != nil {
        return nil, err
    }
    // resp.Body not closed - connection leaked!

    return io.ReadAll(resp.Body)
}

// Called repeatedly
func pollAPI() {
    for i := 0; i < 1000; i++ {
        fetchData("https://api.example.com/data")
        // Each call leaks a connection
        // Eventually exhausts connection pool and file descriptors
    }
}

Why this is vulnerable: HTTP response bodies hold network connections and file descriptors. If resp.Body.Close() isn't called, the connection remains open and can't be returned to http.Transport's idle pool for reuse. Every subsequent request then dials a new connection, and nothing bounds how many: on Go 1.25 http.DefaultTransport has MaxIdleConns: 100, MaxIdleConnsPerHost: 0 - meaning the package default of 2 - and MaxConnsPerHost: 0, which is unlimited. So the caps that exist govern how many idle connections are kept, not how many live ones can be open at once, and a body that is never closed never becomes idle. The process climbs to "too many open files" rather than blocking on a pool.

That distinction decides how the failure looks. A leak here produces a rising descriptor count and, eventually, dial failures in whatever code next opens a file or a socket - which is usually not the leaking call. Garbage collection is no help here, which is the part people expect to save them: measured on Go 1.25, five response bodies dropped without Close() produced zero connection closes at the server after five forced runtime.GC() calls. Nothing releases the connection but an explicit Close().

The half-fixed version is worth knowing too, because it looks correct. A body must be drained as well as closed for the connection to be reused: Close() without reading to EOF makes the transport discard the connection rather than return it to the idle pool. Measured on Go 1.25 against a local server returning a 64 KB body, ten requests over one http.Transport opened one TCP connection when the body was drained with io.Copy(io.Discard, resp.Body) before Close(), and ten when it was only closed. So an early return after checking resp.StatusCode costs a fresh connection on every call even though the defer resp.Body.Close() is right there - no descriptor leak, and no connection reuse either.

Goroutine Leaks from Blocking Operations

func processRequests(requests chan Request) {
    for req := range requests {
        // Spawn goroutine for each request
        go func(r Request) {
            // Blocking operation without timeout
            result := callExternalAPI(r) // Could block forever
            // If this blocks, goroutine never exits
        }(req)
    }
}

// Goroutines accumulate over time
// Each blocked goroutine holds stack memory (2KB+)
// After thousands of blocked goroutines, memory exhausted

Why this is vulnerable: Goroutines are lightweight but not free - each consumes stack memory (minimum 2KB, grows as needed) and runtime overhead. When a goroutine blocks indefinitely (waiting on network I/O, channel recv, mutex lock), it never exits and can't be garbage collected. Unlike a thread leak, which hits the OS thread limit quickly, leaked goroutines accumulate quietly: the application keeps working while memory climbs and scheduler performance degrades. Common causes include: waiting on channels that never receive, HTTP requests without timeouts, database queries without context cancellation, and blocking on mutexes held by crashed goroutines.

Unclosed File Descriptors

// VULNERABLE - Unclosed File Descriptors
func readFile(path string) ([]byte, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    // file not closed - file descriptor leaked!

    return io.ReadAll(file)
}

// After many calls
func readAll() {
    for i := 0; i < 10000; i++ {
        readFile(fmt.Sprintf("data_%d.txt", i))
    }
    // "too many open files" error
}

Why this is vulnerable: Each os.Open consumes a file descriptor from the OS's limited pool (typically 1024-4096 per process on Linux). Without calling file.Close(), descriptors remain allocated even after the function returns. Unlike garbage-collected memory, file descriptors are OS resources that won't be reclaimed until explicitly closed. A server handling thousands of requests per second can exhaust its descriptors in seconds, and every later os.Open, dial or accept then fails - usually somewhere other than the leaking call. The same issue affects network connections (net.Conn), database connections, and any OS handle.

Channels Preventing Garbage Collection

func startWorker() chan Result {
    results := make(chan Result)

    go func() {
        for {
            // Process work
            result := doWork()
            results <- result // Blocks forever if nobody reads
        }
    }()

    return results
}

// Caller starts worker but never closes channel or cancels goroutine
func consume() {
    results := startWorker()
    <-results // Reads one result, then stops reading
    // Goroutine blocked on the next send, never exits, holds channel in memory
}

Why this is vulnerable: Channels and the goroutines using them prevent each other from being garbage collected. If a goroutine blocks sending on a channel that nobody reads from, the goroutine remains alive indefinitely - it is blocked, not terminated - and it holds a reference to the channel. Even if the caller drops its own reference, the goroutine keeps the channel alive. This creates a leak where both the goroutine (stack memory) and channel (buffer memory) remain allocated forever. Proper cleanup requires either closing the channel to signal the goroutine to exit, or using context-based cancellation to terminate the goroutine explicitly.

Secure Patterns

Defer for Resource Cleanup

func readFile(path string) ([]byte, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer file.Close() // Executes when function returns

    return io.ReadAll(file)
    // file.Close() called here, even if ReadAll panics
}

func fetchData(url string) ([]byte, error) {
    resp, err := http.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close() // Critical for connection reuse

    return io.ReadAll(resp.Body)
}

func getUsers(db *sql.DB) ([]User, error) {
    rows, err := db.Query("SELECT id, name FROM users")
    if err != nil {
        return nil, err
    }
    defer rows.Close() // Guaranteed to run

    var users []User
    for rows.Next() {
        var user User
        if err := rows.Scan(&user.ID, &user.Name); err != nil {
            return nil, err // rows.Close() still called!
        }
        users = append(users, user)
    }

    return users, rows.Err()
}

Why this works: Go's defer statement schedules a function call to execute when the surrounding function returns, regardless of whether it returns normally, returns early (error handling), or panics (exception-like behavior). Deferred calls execute in LIFO order (last-deferred, first-executed), ensuring resources are released in the reverse order of acquisition - critical for dependent resources. Unlike try-finally, the defer sits immediately after the acquisition, so the cleanup is visible at the point the resource is obtained. It also fits Go's error-handling idiom of early returns: every return path triggers the deferred cleanup, so the error cases leak nothing either. This is the primary mechanism for reliable resource management in Go.

Context-Based Goroutine Cancellation

import (
    "context"
    "time"
)

func worker(ctx context.Context, id int) {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            // Do work
            processTask(id)
        case <-ctx.Done():
            // Context cancelled - exit goroutine
            fmt.Printf("Worker %d exiting: %v\n", id, ctx.Err())
            return
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // Ensure all goroutines exit

    // Start workers
    for i := 0; i < 10; i++ {
        go worker(ctx, i)
    }

    // Wait for signal to shutdown
    <-shutdownSignal

    cancel()                           // Signal all workers to exit
    time.Sleep(100 * time.Millisecond) // Wait for cleanup

    // All goroutines exited - no leak
}

Why this works: Context-based cancellation is the standard way to tell a goroutine to stop. The ctx.Done() channel is closed when the context is cancelled, by the cancel() function or by a timeout, so a goroutine selecting on it in its main loop unblocks, returns, and becomes collectable. This pattern works for any number of goroutines - one cancel() call signals all of them. Contexts can be nested (child contexts inherit parent cancellation) and support timeouts (context.WithTimeout) to prevent indefinite blocking. This is essential for server applications that spawn goroutines per request - cancelling the request context ensures all spawned goroutines exit when the request completes.

Proper HTTP Client with Timeouts

func fetchDataSafely(ctx context.Context, url string) ([]byte, error) {
    // Create request with context for cancellation
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }

    // Use client with timeouts to prevent indefinite blocking
    client := &http.Client{
        Timeout: 10 * time.Second,
    }

    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close() // Always close response body

    // Read body with io.ReadAll - automatically handles errors
    return io.ReadAll(resp.Body)
}

// Usage with context timeout
func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    data, err := fetchDataSafely(ctx, "https://api.example.com/data")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Write(data)
}

Why this works: http.NewRequestWithContext ties the request to a context, so cancelling it aborts the request in flight. Setting client.Timeout stops a request blocking indefinitely - once the timeout fires, the request is cancelled and its connections are closed. defer resp.Body.Close() releases the body on the error paths as well as the success path, returning the connection to the pool. Taking the context from r.Context() in a handler cancels the outbound request when the client disconnects, so an abandoned request does not strand the goroutines working on it. This is the recommended pattern for production HTTP clients in Go.

Closing Channels to Signal Completion

func producer(ctx context.Context) <-chan int {
    ch := make(chan int)

    go func() {
        defer close(ch) // Signal completion by closing channel

        for i := 0; ; i++ {
            select {
            case ch <- i:
                // Sent successfully
            case <-ctx.Done():
                // Context cancelled - exit
                return
            }
        }
    }()

    return ch
}

func consumer(ctx context.Context) {
    ch := producer(ctx)

    for val := range ch {
        // Process values
        // range exits when channel is closed
        process(val)
    }

    // Channel closed, goroutine exited - no leak
}

Why this works: Closing a channel tells every receiver that no more values are coming, so none of them blocks forever on a value that will never arrive. A range loop over a channel exits when the channel closes, so the consumer needs no separate exit condition. defer close(ch) in the producer closes the channel whether that goroutine returns normally or on context cancellation, so producer and consumer both unwind. This is the idiomatic Go pattern for producer-consumer pipelines.

Worker Pool with Proper Shutdown

import (
    "context"
    "sync"
)

type WorkerPool struct {
    workers int
    jobs    chan Job
    wg      sync.WaitGroup
}

func NewWorkerPool(workers int) *WorkerPool {
    return &WorkerPool{
        workers: workers,
        jobs:    make(chan Job, 100),
    }
}

func (p *WorkerPool) Start(ctx context.Context) {
    for i := 0; i < p.workers; i++ {
        p.wg.Add(1)
        go p.worker(ctx, i)
    }
}

func (p *WorkerPool) worker(ctx context.Context, id int) {
    defer p.wg.Done()

    for {
        select {
        case job, ok := <-p.jobs:
            if !ok {
                // Channel closed - exit
                return
            }
            job.Process()
        case <-ctx.Done():
            // Context cancelled - exit
            return
        }
    }
}

func (p *WorkerPool) Submit(job Job) {
    p.jobs <- job
}

func (p *WorkerPool) Shutdown() {
    close(p.jobs) // Signal workers to exit after draining jobs
    p.wg.Wait()   // Wait for all workers to exit
}

// Usage
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    pool := NewWorkerPool(10)
    pool.Start(ctx)

    // Submit jobs
    for i := 0; i < 100; i++ {
        pool.Submit(Job{ID: i})
    }

    // Graceful shutdown
    pool.Shutdown()

    // All goroutines exited - no leak
}

Why this works: Closing the jobs channel tells workers to exit once the remaining jobs are drained - the graceful path. The sync.WaitGroup tracks running workers: Add(1) before starting each, Done() as it exits, and Wait() blocks until all of them have finished. The select lets a worker leave either when the jobs channel closes or when the context is cancelled, the forced path. Without one of the two, the workers block on the channel forever, which is how an application that creates and discards worker pools dynamically leaks goroutines and memory.

Detecting Leaks

A leaked goroutine is still running, so nothing fails and no error is returned. The cheapest check is to count goroutines around an operation that should leave none behind:

func TestHandlerLeavesNoGoroutines(t *testing.T) {
    before := runtime.NumGoroutine()

    handleRequest(context.Background(), req)

    // Goroutines may still be unwinding, so allow a moment before comparing.
    time.Sleep(100 * time.Millisecond)
    if after := runtime.NumGoroutine(); after > before {
        t.Fatalf("goroutine leak: %d before, %d after", before, after)
    }
}

For anything more involved, pprof names the leaked stacks directly, which turns "memory climbs overnight" into a specific line:

go test -race ./...                     # races often accompany lifecycle bugs
go tool pprof http://localhost:6060/debug/pprof/goroutine
go tool pprof http://localhost:6060/debug/pprof/heap

Watch buffered channels while you are there. A buffer bounds how far a producer can outrun a consumer, but a buffer sized from a request count rather than a fixed limit grows without bound, which shows up as heap growth rather than as a blocked goroutine.

Additional Resources