[Jens Neuse](/content/people/jens-neuse/index.html)

CEO & Co-Founder at WunderGraph

May 10, 2025·14min read

Edited on July 2, 2026 by [Brendan Bondurant](/content/people/brendan-bondurant/index.html)

Go makes it very easy to write concurrent code. Start a few goroutines, wait for all of them to finish, and done. The `go` keyword makes this a breeze, and with the `sync.WaitGroup` primitive, synchronization can be achieved with no hassle. But is it really _that_ simple? (Spoiler: Sometimes, not quite.)

At WunderGraph, we're using Golang to build a GraphQL Router that splits an incoming request into multiple smaller requests to different services. We then merge the results back together to form the final response. As you can imagine, you'd want your Router to be as efficient as possible, so you execute as many of these smaller requests in parallel as possible.

Consequently, we had to dig quite deep into the guts of writing concurrent code in Go. This post aims to share some of the lessons we learned because slapping `go` in front of everything and using `sync.WaitGroup` without fully understanding it can easily lead to bugs like deadlocks or improper cancellation.

## TL;DR

Go's `sync.WaitGroup` lets you wait for a group of goroutines to finish, but using it correctly means understanding its internals and common pitfalls. Forgetting `defer wg.Done()`, skipping `context.Context` in blocking operations, and wrapping `wg.Wait()` in a timeout channel all lead to deadlocks or goroutine leaks. When error handling and coordinated cancellation matter, `errgroup.Group` from `golang.org/x/sync/errgroup` is the better tool: it collects the first error and cancels remaining goroutines automatically. Go 1.25, released in August 2025, added a `wg.Go()` method that removes the need for separate `Add` and `Done` calls.

## What is Golang's sync.WaitGroup?

Let's start with a very simple example:

```go
package main

import (
	"fmt"
	"sync"
)

func main() {
	wg := &sync.WaitGroup{}
	// We need to wait for one goroutine, so we Add(1) *before* starting it.
	wg.Add(1)
	go func() {
		// Defer Done() right away to ensure it's called, even if the goroutine panics or returns early.
		defer wg.Done()
		fmt.Println("Hello, World!")
	}()
	wg.Wait() // Wait blocks until the counter is zero.
}
```

You can try out the code yourself in the [Go Playground](https://go.dev/play/p/C-tj_YX43e2). If you remove the `wg.Wait()` call, you will see that the program exits before the goroutine is able to print the message.

Like the name suggests, `WaitGroup` is a primitive that allows us to wait for a group of goroutines to finish. However, we didn't really leverage the "group" aspect of it, so let's make our example slightly more complex and realistic.

```go
package main

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

// Let's imagine this is our combined Product model
type Product struct {
	ID          int
	Availability string
	Price       float64
}

func main() {
	wg := &sync.WaitGroup{}
	product := &Product{ID: 1}

// We need to make two calls.
	// It's crucial to call Add for *each* goroutine before starting it.

// Goroutine to fetch availability
	wg.Add(1)
	go func() {
		// Always defer Done() immediately inside the goroutine.
		defer wg.Done()
		// Simulate network call to a "Products" subgraph
		fmt.Println("Fetching availability for product 1...")
		// Pretend GraphQL query: query { product(id:1) { availability } }
		time.Sleep(100 * time.Millisecond) // Simulate network latency
		product.Availability = "In Stock"
		fmt.Println("Availability fetched.")
	}()

// Goroutine to fetch price
	wg.Add(1)
	go func() {
		// Defer Done() right away!
		defer wg.Done()
		// Simulate network call to a "Pricing" subgraph
		fmt.Println("Fetching price for product 1...")
		// Pretend GraphQL query: query { product(id:1) { price } }
		time.Sleep(150 * time.Millisecond) // Simulate network latency
		product.Price = 29.99
		fmt.Println("Price fetched.")
	}()

// Wait for both goroutines to complete
	fmt.Println("Router is waiting for subgraph responses...")
	wg.Wait() // This blocks until the counter hits zero (both Done() calls have happened)

fmt.Printf("Successfully fetched data for Product ID %d: %+v\n", product.ID, *product)
}
```

Here's a link to the [Go Playground](https://go.dev/play/p/CV-O34R2KyW) to run the code yourself. Now we're doing something that resembles a real-world scenario and we're using the `WaitGroup` to wait for both goroutines to finish.

## How does sync.WaitGroup Actually Work? (The Nitty Gritty)

The `WaitGroup` implementation, although just 129 lines of code, is actually quite interesting to look at. We can learn a lot about writing concurrent code in Go and about the runtime and the Go scheduler.

Let's take a look at the `WaitGroup` struct:

The first thing that stands out is the `noCopy` field. This isn't data; it's a clever trick. If you try to copy a `WaitGroup` after its first use, the Go `vet` tool will yell at you. Why? Because copying it would mean the counter and waiters wouldn't be shared correctly, leading to chaos. Think of it like trying to photocopy a shared to-do list – everyone ends up with different versions!

The second thing is the `state` field, an `atomic.Uint64`. This is where the magic happens. Instead of using separate variables (and a mutex!) for the goroutine counter (how many `Done()` calls are still needed) and the waiter counter (how many goroutines are blocked on `Wait()`), it packs them both into one 64-bit integer. The high 32 bits track the main counter, and the low 32 bits track the waiters. This atomic variable allows multiple goroutines to update and read the state safely and efficiently without needing locks, in most cases. Pretty neat, huh?

Finally, the `sema` field is a semaphore used internally by the Go runtime. When a goroutine calls `Wait()` and the counter isn't zero, it essentially tells the runtime, "Okay, put me to sleep on this semaphore (`runtime_SemacquireWaitGroup`)." When the counter _does_ hit zero (because the last `Done()` was called), the runtime is signaled to wake up _all_ the goroutines sleeping on that semaphore (`runtime_Semrelease`). This `sema` field is key to understanding potential issues, as we'll see later.

In a nutshell, the `WaitGroup` lifecycle is:

1. Call `Add(n)` _before_ starting your goroutines to tell the `WaitGroup` how many `Done()` calls to expect. A common pattern is `wg.Add(1)` right before each `go` statement.
2. Inside each goroutine, call `defer wg.Done()` _immediately_ to ensure the counter is decremented when the goroutine finishes, no matter what.
3. Call `Wait()` where you need to block until all `n` goroutines have called `Done()`.

Now that we've peeked behind the curtain, let's talk about where things can go wrong.

## Pitfalls of using the sync.WaitGroup in Golang

One of the most important lessons I've learned about Go is that you should always understand the lifecycle of a goroutine. Launching them is easy with `go`, but you _must_ think about how they will eventually end. This is especially critical when using `WaitGroup`.

### Deadlock due to improper counter management

Let's look at a common mistake. What if we forget to call `Done()` under certain conditions?

```go
// Example showing deadlock due to missing Done call.
```

In case of an error during request creation or sending, the `Done()` method is skipped. The `WaitGroup` counter never reaches zero, and our main goroutine calling `wg.Wait()` blocks indefinitely. Classic deadlock!

If you run similar code locally, or sometimes in the [Go Playground](https://go.dev/play/p/GNHMWvv7qb4), you'll eventually get this dreaded output:

```go
// Output leading to deadlock.
```

The fix is simple but crucial: use `defer wg.Done()` _at the very beginning_ of the goroutine.

### Improper context cancellation when using WaitGroup

Our HTTP request example still has a subtle but dangerous problem: we're not passing a `context.Context`. The `http.DefaultClient` might hang forever waiting for a response if the network is slow or the server is unresponsive. If the HTTP call blocks, `wg.Done()` never runs, and `wg.Wait()` blocks forever. Back to deadlock city!

So how can we add a timeout to `wg.Wait()` itself? You might see this pattern suggested online or by an LLM:

```go
// Example showing timeout.
```

This _seems_ like it solves the main goroutine blocking forever. We use a `select` statement with a timeout. If `wg.Wait()` finishes within 5 seconds, the `done` channel is closed and we proceed. If 5 seconds pass, the `time.After` case triggers and we print a timeout message. Problem solved?

**No! This is a trap!** We've introduced a potential **goroutine leak**.

Think about what happens in the timeout case:

1. The `select` statement in `main` proceeds because `time.After` fires.
2. The goroutine running `wg.Wait()` is _still blocked_ waiting for the counter to hit zero. Remember `runtime_SemacquireWaitGroup`? It's stuck there.
3. The original worker goroutine (doing the HTTP request) might _also_ still be blocked, waiting indefinitely on the network call because we didn't give it a context with a deadline or cancellation signal. `defer wg.Done()` hasn't run yet!

So, `main` continues, but we've potentially left _two_ goroutines hanging around, consuming resources, unable to finish. They are leaked. This is bad, especially in long-running server applications. Like I said earlier: always reason about how your goroutines _end_!

The `WaitGroup` itself doesn't support cancellation. `wg.Wait()` is fundamentally a blocking call until the counter reaches zero. The _real_ fix is to make sure the _work_ being done inside the goroutines can be cancelled.

Let's bring `context.Context` to the rescue:

```go
// Example showing context use with WaitGroup.
```

You can run the code in [Go Playground](https://go.dev/play/p/KidD4BvLGk8). We now pass a context with a timeout to `http.NewRequestWithContext`, and the `http.Client` respects this context. If the request takes longer than the context's deadline, `Do` will return an error (usually `context.DeadlineExceeded`). Crucially, the goroutine _unblocks_ and proceeds to its end, executing the deferred `wg.Done()`.

Now, `wg.Wait()` is safe to call directly. We don't need the leaky `done` channel hack anymore. If all worker goroutines properly handle context cancellation, `wg.Wait()` will eventually return.

To be extra sure you're not leaking goroutines in your tests, you can use packages like [`goleak`](https://github.com/uber-go/goleak). Highly recommended!

So, context is vital. But what if we also need to handle errors from our goroutines and potentially cancel _other_ running tasks if one fails? `WaitGroup` doesn't help there. Enter its more sophisticated cousin...

## Alternatives to sync.WaitGroup: When to use `errgroup.Group`

So far, we've focused on just _waiting_ for tasks to finish. We haven't really considered what happens if one of those tasks fails or if we want to collect errors. `WaitGroup` isn't concerned with errors.

This is fine if partial success is okay. Maybe you're firing off notifications and it's not critical if one fails.

Still, in scenarios like our GraphQL Federation example, failure is not an option. If fetching the price fails, the entire product response is incomplete and likely useless. We need to know about the error, and maybe we shouldn't even bother waiting for the availability call to finish if the price call already failed.

This is where `errgroup.Group` shines. It's designed specifically for running a group of tasks where you care about errors and want coordinated cancellation. It lives in the `golang.org/x/sync/errgroup` package.

Let's refactor our product example using `errgroup.Group`:

```go
// Example using errgroup.Group for error handling and cancellation.
```

Notice the key differences:

1. We use `errgroup.WithContext(context.Background())` to create the group and a derived context.
2. We use `eg.Go(func() error { ... })` to launch goroutines. `errgroup` manages the waiting internally (no manual `Add`/`Done`).
3. Our worker functions (`fetchAvailability`, `fetchPrice`) now accept and _respect_ the `context.Context`. This is crucial for cancellation.
4. `eg.Wait()` blocks until all `eg.Go` functions complete. It returns the _first_ non-nil error encountered.
5. **The magic:** If any function passed to `eg.Go` returns a non-nil error, `errgroup` automatically cancels the `ctx` it created. Any _other_ running goroutines launched via `eg.Go` that are properly checking `ctx.Done()` (like ours using `select`) will receive the cancellation signal and can exit early. This prevents wasted work.

This behavior is exactly what we often need for scenarios like API gateways or data fetching: fail fast, clean up, and report the first problem.

## TL;DR: The WaitGroup & errgroup Cheat Sheet (For the Impatient Gopher)

Okay, that was a lot. Here's the quick version:

**Use `sync.WaitGroup` when:**

- You just need to wait for several independent goroutines to finish.
- You don't care too much if some of them error (or they handle errors internally).
- Partial success is acceptable.
- **Remember:** Call `wg.Add(1)` _before_`go`, `defer wg.Done()` _inside_, and ensure goroutines handle `context.Context` if they do blocking I/O to prevent leaks when using `wg.Wait()`.

**Use `golang.org/x/sync/errgroup` when:**

- You need to run several goroutines and get the **first error** that occurs.
- You want other goroutines to be **cancelled** automatically if one fails.
- Your goroutines perform work that should respect cancellation (e.g., network calls, long computations).
- **Remember:** Use `eg, ctx := errgroup.WithContext(...)`, launch with `eg.Go(func() error { ... })`, pass `ctx` into your work functions and check `ctx.Done()`, and check the `err` returned by `eg.Wait()`.

**General Go Concurrency Wisdom:**

- Always think about the **entire lifecycle** of your goroutines (how do they start, how do they _stop_?).
- Use `context.Context` religiously for cancellation and deadlines in any blocking or long-running operation.
- `defer wg.Done()` is your friend for `WaitGroup`.
- Test for goroutine leaks using tools like `goleak`.

## Go 1.25 made WaitGroup more ergonomic

Go 1.25, released in August 2025, shipped a [`Go` method](https://go.dev/doc/go1.25) on `WaitGroup` that makes the common pattern of creating and counting goroutines more convenient.

Here's what the usage looks like:

```go
// Example using wg.Go() for better ergonomics.
```

The benefits:

- **Simplified syntax:** Removes the need for separate Add and go statements.
- **Reduced errors:** Removes the risk of forgetting to call Done or calling Add after the goroutine has started.
- **Improved readability:** Makes the code more concise and easier to understand.

## Conclusion

When I first encountered `WaitGroup`, like many, I wondered, "Why doesn't `Wait()` just take a context?" It seemed like an obvious omission. But diving deeper, you realize `WaitGroup` is intentionally simple, a low-level primitive focused purely on counting. Its simplicity is its strength, but also its limitation. `errgroup` builds upon these ideas to provide the richer error handling and cancellation logic needed for more complex concurrent patterns.

The big takeaway? Go makes concurrency _accessible_, but not necessarily _easy_ to get right. The primitives are powerful, but subtle bugs like deadlocks and goroutine leaks are easy to introduce if you don't understand the underlying mechanics (like context propagation and how `Wait` blocks). There's no magic linter that catches all these concurrency nuances; it requires careful design, testing (including leak detection!), and code review.

I hope this deeper dive into the world of `WaitGroup` and `errgroup` helps you navigate the sometimes-choppy waters of Go concurrency with a bit more confidence! May your goroutines always finish, and your channels never deadlock (unless you want them to, which is weird, but okay).
