How to Wait for Goroutines to Finish with sync.WaitGroup

Use `sync.WaitGroup` to synchronize goroutines by incrementing its counter before launching each one and calling `Done()` when they finish, then block the main thread with `Wait()` until the counter reaches zero.

The clipboard problem

You write a Go program that fetches data from three different APIs. You spawn a goroutine for each request so they run in parallel. The main function reaches the end of its code, prints a success message, and exits. The program terminates. The goroutines are still waiting for network responses. They never finish. The operating system cleans up the process, and your data is lost.

This happens because the main goroutine does not automatically wait for background goroutines. Go treats every goroutine as an independent unit of work. If the main function returns, the entire process shuts down regardless of what other goroutines are doing. You need a synchronization primitive that pauses the main goroutine until a specific set of background tasks completes.

That primitive is sync.WaitGroup. It is a counter designed specifically for coordinating goroutine lifecycles. You tell it how many tasks are starting, you decrement it when each task finishes, and you block until the counter reaches zero. No magic, just a shared integer protected by the runtime scheduler.

WaitGroup tracks completion, not results. It answers the question "are we done?" rather than "what did we get?" Keep that boundary clear and your concurrency code stays readable.

How the counter works

Think of a construction foreman holding a clipboard. Every worker who starts a task gets a checkmark on the sheet. When a worker finishes, they cross their mark off. The foreman refuses to leave the site until the clipboard is completely empty. If a worker leaves early, the foreman notices the missing cross-off and waits indefinitely. If a worker tries to cross off a mark that was never written, the clipboard breaks.

sync.WaitGroup implements this exact pattern. It exposes three methods: Add, Done, and Wait. You call Add(n) to register n tasks. Each task calls Done() when it finishes, which is equivalent to Add(-1). The main goroutine calls Wait(), which parks the goroutine on an internal wait queue until the counter hits zero.

The counter is not a plain int. The Go runtime pads the struct to avoid false sharing on multi-core CPUs. False sharing occurs when two CPU cores modify different variables that happen to live on the same cache line, causing constant cache invalidation. The padding keeps the WaitGroup isolated in memory so concurrent increments and decrements do not throttle the CPU.

The runtime also tracks waiters internally. When Wait() is called, the scheduler marks the goroutine as blocked and switches to another runnable goroutine. When the last Done() call drops the counter to zero, the scheduler wakes all blocked goroutines and puts them back in the ready queue. This parking and unparking happens at the OS thread level, but the Go scheduler abstracts it away so you never touch pthread_cond_wait or similar primitives.

WaitGroup is for coordination. Channels are for communication. Mixing the two purposes in one primitive creates tangled code.

Minimal example

Here is the simplest pattern: register the work, spawn the goroutines, defer the cleanup, and block until everything finishes.

package main

import (
	"fmt"
	"sync"
	"time"
)

// worker simulates a background task and decrements the group counter when done.
func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // Guarantees counter decrement even if the function panics
	fmt.Printf("Worker %d starting\n", id)
	time.Sleep(1 * time.Second) // Simulates network or disk I/O
	fmt.Printf("Worker %d done\n", id)
}

func main() {
	var wg sync.WaitGroup

	// Register three tasks before launching any goroutines
	for i := 1; i <= 3; i++ {
		wg.Add(1) // Increment the internal counter
		go worker(i, &wg) // Start the background task
	}

	// Block the main goroutine until the counter reaches zero
	wg.Wait()
	fmt.Println("All workers finished")
}

The defer wg.Done() call is a community standard. Deferring it at the top of the goroutine function ensures the counter decrements exactly once, regardless of early returns or panics. The Go community accepts the slight verbosity of defer here because it eliminates an entire class of synchronization bugs.

Always pass the WaitGroup by pointer. Structs in Go are passed by value, and copying a WaitGroup breaks its internal state. The compiler will not stop you from copying it, but the runtime will panic immediately.

What happens under the hood

When main calls wg.Add(1), the runtime atomically increments the first half of the WaitGroup's internal counter. The second half tracks how many goroutines are currently blocked in Wait(). This split design prevents a race condition where a goroutine calls Wait() at the exact same moment the last Done() drops the counter to zero.

If Wait() sees a positive counter, it increments the waiter count and parks the goroutine. The scheduler removes it from the ready queue and runs something else. When the final Done() executes, it decrements the task counter. If the counter reaches zero and the waiter count is greater than zero, the runtime wakes all blocked goroutines and resets the waiter count.

This mechanism is intentionally simple. WaitGroup does not support timeouts, cancellation, or result passing. It is a single-purpose synchronization barrier. Adding complexity to it would defeat the purpose of keeping concurrency primitives orthogonal.

The runtime also enforces strict lifecycle rules. You cannot reuse a WaitGroup until every goroutine that called Wait() has returned. Attempting to call Add on a WaitGroup that is still being waited on triggers a panic. The runtime tracks this state to prevent subtle deadlocks where a new batch of tasks starts before the previous batch finishes.

Simplicity prevents deadlocks. Complexity invites them.

Realistic batch processing

Real code rarely processes exactly three tasks. You usually read a slice of items, fan out work, and collect results. WaitGroup shines when you need to signal that all producers are finished so a consumer can close a channel and exit cleanly.

Here is a pattern that processes a dynamic list of URLs, sends results to a channel, and uses WaitGroup to close the channel once all fetchers complete.

package main

import (
	"fmt"
	"net/http"
	"sync"
)

// fetcher retrieves a URL and sends the status code to a channel.
func fetcher(url string, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done() // Decrement counter when this fetch completes
	resp, err := http.Get(url) // Perform the network request
	if err != nil {
		results <- 0 // Send zero on failure to keep the channel flowing
		return
	}
	defer resp.Body.Close() // Release the underlying connection
	results <- resp.StatusCode // Send the HTTP status code
}

func main() {
	urls := []string{
		"https://example.com",
		"https://golang.org",
		"https://httpbin.org/status/200",
	}
	
	var wg sync.WaitGroup
	results := make(chan int, len(urls)) // Buffer matches task count to prevent blocking

	for _, u := range urls {
		wg.Add(1) // Register one fetch task
		go fetcher(u, results, &wg) // Launch concurrent fetch
	}

	// Close the channel exactly when all fetchers finish
	go func() {
		wg.Wait()
		close(results) // Signals consumers that no more values are coming
	}()

	// Drain the channel and print results
	for code := range results {
		fmt.Printf("Got status: %d\n", code)
	}
}

The buffered channel prevents goroutines from blocking on the send operation when the main goroutine is still setting up. The separate goroutine calling wg.Wait() and close(results) ensures the main loop can continue draining values without deadlocking. This is the idiomatic fan-out/fan-in pattern in Go.

Channels carry data. WaitGroup carries signals. Keep them in their lanes.

Pitfalls and runtime panics

WaitGroup is safe by design, but it will panic if you misuse it. The runtime catches mistakes early rather than letting them corrupt memory.

Calling wg.Add(1) after go worker() creates a race condition. The goroutine might finish and call Done() before the main goroutine increments the counter. The counter drops to zero, Wait() returns immediately, and the main goroutine exits while the worker is still running. The compiler does not catch this because the race happens at runtime. Always call Add before go.

Calling Done() more times than Add triggers a panic with sync: negative WaitGroup counter. This usually happens when you forget to defer Done() and accidentally call it twice, or when you reuse a WaitGroup without resetting it. The runtime refuses to continue because a negative counter indicates broken synchronization logic.

Copying a WaitGroup triggers a panic with sync: WaitGroup is reused before previous Wait has returned or sync: negative WaitGroup counter depending on the Go version. The struct contains internal mutexes and waiter queues. Copying it creates two independent structs pointing to the same memory, which breaks the atomic guarantees. Always pass &wg.

The compiler rejects unused imports with imported and not used, but it will happily let you compile a program with a broken WaitGroup pattern. The bug only surfaces when the program runs. Write small concurrency tests that assert completion times or use t.Fatalf in parallel test functions to catch synchronization errors before they reach production.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.

When to reach for WaitGroup

Concurrency primitives solve different problems. Picking the wrong one adds latency, memory overhead, or unnecessary complexity. Match the tool to the constraint.

Use sync.WaitGroup when you need to block until a fixed or dynamic set of goroutines finishes, and you do not need to pass results back to the main goroutine. Use a buffered channel when you need to collect results, errors, or status updates from concurrent workers and want the main goroutine to process them as they arrive. Use context.Context when you need to cancel long-running goroutines or enforce deadlines, and always pass it as the first parameter to any function that might block. Use sync.Mutex when multiple goroutines need to read and write the same variable safely, and keep the critical section as short as possible. Use plain sequential code when you do not need concurrency, because the simplest thing that works is usually the right thing.

WaitGroup is a barrier, not a pipeline. Treat it as such.

Where to go next