K AVAILABLE
← BACK TO WRITING

WRITING

Why defer Saved My Go Concurrency Code From a Silent Deadlock

Any function with error handling has multiple exit paths that can skip cleanup. Here's how Go's defer prevents silent goroutine deadlocks.

Published Aug 4, 20263 min readlesson
Why defer Saved My Go Concurrency Code From a Silent Deadlock Cover
THE TAKEAWAYAny function with error handling has multiple exit paths that can silently skip cleanup. Pairing defer with resource acquisition guarantees execution and prevents silent deadlocks.

Overview

Coming from JavaScript and Java, Go seemed pleasantly minimal.

When writing concurrent worker pools in Go, a common pattern is using sync.WaitGroup to synchronize goroutine completion. But if you call wg.Done() manually at the bottom of a function, early returns across error branches can silently skip the signal, leaving wg.Wait() hanging forever with no panic, no error, and no stack trace.

Here is why placing defer immediately next to resource acquisition and synchronization saves your concurrency code from silent deadlocks.

The Problem: Early Returns Bypass Cleanup

My first concurrent worker pool called wg.Done() at the very bottom of the worker function:

go
func worker(id int, wg *sync.WaitGroup) {
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second) // simulate work
    fmt.Printf("Worker %d finished\n", id)
    wg.Done() // signaling completion at the very end
}

This works fine as long as nothing goes wrong — but production code doesn't run top to bottom without hiccups. As soon as an early return was added for an error case, wg.Done() was skipped entirely:

go
func worker(id int, wg *sync.WaitGroup) {
    fmt.Printf("Worker %d starting\n", id)
    err := processJob(id)
    if err != nil {
        fmt.Println("Job failed:", err)
        return // wg.Done() never runs!
    }
    fmt.Printf("Worker %d finished\n", id)
    wg.Done()
}

Result: wg.Wait() on the main goroutine never reaches zero, and the process hangs silently — no panic, no error, no stack trace.

The Fix: Immediate `defer`

defer schedules a call to run when the enclosing function returns, regardless of how it returns (normal exit, early return, or panic):

go
func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // guaranteed to run on any return path
    fmt.Printf("Worker %d starting\n", id)
    err := processJob(id)
    if err != nil {
        fmt.Println("Job failed:", err)
        return // wg.Done() still fires
    }
    fmt.Printf("Worker %d finished\n", id)
}

The key habit: pair the defer with the acquire step immediately (e.g., defer wg.Done() right after wg.Add(1) logic is set up, defer file.Close() right after opening a file, defer mu.Unlock() right after mu.Lock()), rather than waiting until you've written the rest of the function.

LIFO Ordering

Multiple deferred calls in the same function run in reverse (last-deferred-first) order:

go
func demo() {
    defer fmt.Println("1: unlock mutex")
    defer fmt.Println("2: close file")
    defer fmt.Println("3: done signal")
}
// prints: 3, 2, 1

This matters when cleanup steps have dependencies on each other — order your defers deliberately, not just in the order they came to mind.

Caveat: Overhead in Tight Loops

defer has a small performance cost compared to a direct call. This is negligible for goroutine-per-request or job-per-worker code, but worth avoiding inside extremely hot, tight loops (millions of iterations) — there, manual cleanup at each exit point may be preferable.

The Takeaway

Any function with error handling has multiple exit paths, and each one can silently skip cleanup you assumed was guaranteed. defer, placed immediately next to the resource acquisition, removes that risk and prevents an entire class of "why is my server just hanging" bugs.

TOPICS:
GoGolangConcurrencyGoroutinesBackend EngineeringBest Practices