Skip to main content
Concurrency Pitfalls & Patterns

Stop Hopping Between Threads: 4 Concurrency Traps That Stall Your Go App

The Hidden Cost of Unbounded GoroutinesOne of Go's greatest strengths is the simplicity of launching goroutines with the go keyword. However, many developers treat goroutines as free resources, spawning them without thought for lifecycle management. In a typical microservice handling thousands of requests per second, every request might spin off multiple goroutines for logging, database queries, and downstream calls. Without careful control, these goroutines can accumulate faster than they complete, especially under high load or during upstream service delays.Consider a team that built a notification service where each incoming event spawned a goroutine to send an email, another to update a database, and a third to push a real-time alert. Under normal traffic, the service ran fine. But during a marketing campaign, event volume spiked tenfold. The goroutines piled up, each holding memory for stack and heap allocations. The Go runtime scheduler became overwhelmed, context-switching between thousands of runnable goroutines.

The Hidden Cost of Unbounded Goroutines

One of Go's greatest strengths is the simplicity of launching goroutines with the go keyword. However, many developers treat goroutines as free resources, spawning them without thought for lifecycle management. In a typical microservice handling thousands of requests per second, every request might spin off multiple goroutines for logging, database queries, and downstream calls. Without careful control, these goroutines can accumulate faster than they complete, especially under high load or during upstream service delays.

Consider a team that built a notification service where each incoming event spawned a goroutine to send an email, another to update a database, and a third to push a real-time alert. Under normal traffic, the service ran fine. But during a marketing campaign, event volume spiked tenfold. The goroutines piled up, each holding memory for stack and heap allocations. The Go runtime scheduler became overwhelmed, context-switching between thousands of runnable goroutines. Response latency increased from 50ms to over 5 seconds, and the service eventually ran out of memory, causing a crash.

The Mechanics of Goroutine Leaks

A goroutine leak occurs when a goroutine never exits, often because it's blocked forever on a channel send or receive, or waiting on a mutex that never gets unlocked. The Go runtime does not garbage-collect goroutines; they remain in memory indefinitely. Leaked goroutines accumulate, consuming CPU time from the scheduler and memory for their stacks. In production, a single leaked goroutine may seem harmless, but over hours or days, thousands of leaks can degrade performance and cause out-of-memory errors.

The root cause is often a missing or incorrect termination signal. For example, a goroutine waiting on a channel that no one writes to will block forever. Similarly, a goroutine that reads from a network connection may never return if the connection is not closed properly. To prevent leaks, every goroutine should have a clear exit path—either through a context cancellation, a done channel, or a timeout.

Detecting and Preventing Leaks

Use the runtime.NumGoroutine function to monitor goroutine count in development or via metrics. Set up alerts if the count exceeds a threshold. The pprof tool can capture goroutine profiles: go tool pprof http://localhost:6060/debug/pprof/goroutine. Look for large numbers of goroutines stuck in the same state, such as chan receive or IO wait. These indicate leaks. To fix, apply patterns like worker pools (fixed number of goroutines processing jobs from a channel) or use sync.WaitGroup to ensure all goroutines complete before shutdown. Always pass a context.Context to goroutines and check ctx.Done() inside long-running loops.

Unbounded goroutines are a silent performance killer. By enforcing lifecycle management and monitoring goroutine counts, you can avoid memory exhaustion and latency spikes. Next, we'll explore how channel misuse can create similar stalls.

Channel Misuse: Blocking and Deadlocks

Channels are Go's primary communication mechanism, but they are easy to misuse. Unbuffered channels require both sender and receiver to be ready simultaneously; if one side is missing, the goroutine blocks forever. Buffered channels help, but they can mask deadlocks until the buffer fills. A common mistake is to use channels without considering edge cases like closed channels or zero-value sends.

Picture a service that processes user uploads. The main goroutine reads files and sends file paths to a channel, while worker goroutines process them. The developer used an unbuffered channel and launched workers in a loop, but forgot to close the channel after sending all paths. Workers reading from the channel eventually block waiting for data that never comes, but they don't exit. Meanwhile, the main goroutine exits, leaving the workers stranded. This is a classic deadlock pattern: the program hangs because no more data is sent, but workers don't know to stop.

Patterns for Safe Channel Usage

Always close a channel from the sender side when no more values will be sent, and signal completion. Use the range loop to read from a channel until it's closed. For multiple senders, use a sync.WaitGroup to coordinate closing: one goroutine waits for all senders to finish, then closes the channel. Alternatively, use a dedicated done channel that workers select on alongside the data channel. This pattern allows workers to exit early if cancellation is requested.

Another trap is sending on a nil channel. A nil channel blocks forever on both send and receive. This can happen if a channel variable is declared but not initialized with make(). Always initialize channels before use. Also, avoid mixing buffered and unbuffered channels in the same pipeline without clear reasoning. Buffered channels decouple sender and receiver, which can improve throughput but also introduces backpressure issues if the buffer size is too large.

Real-World Channel Deadlock Scenario

In a real incident, a team's data pipeline used a chain of channels: producer → transformer → consumer. The transformer read from an input channel, processed data, and wrote to an output channel. The consumer read from the output channel. Under normal load, the transformer processed faster than the producer, so the input channel buffer never filled. But when the producer temporarily sped up, the input channel buffer filled, and the transformer blocked on sending to the output channel because the consumer was slow. The transformer could not read from the input channel while blocked on the output send, causing a deadlock. The fix was to use buffered channels with appropriate capacities or to use a separate goroutine for each stage with backpressure handling.

Channel misuse leads to deadlocks and stalls that are hard to reproduce. Adopt patterns like sender-side close, select with done channels, and proper buffer sizing. Next, we'll look at synchronization primitives that are often applied incorrectly.

Improper Synchronization with Mutexes and WaitGroups

When goroutines share state, mutexes and WaitGroups are essential. But incorrect use can cause race conditions or deadlocks. A common error is forgetting to unlock a mutex, especially when a function has multiple return paths. Another is copying a sync.Mutex by value, which duplicates the lock state and leads to undefined behavior.

Consider a cache that stores computed results in a map. A naive implementation locks the map, checks for a key, computes the value if missing, and stores it. If multiple goroutines call this function concurrently, they may all find the key missing, compute the same value, and overwrite each other. This is a read-modify-write race. The fix is to use double-checked locking with a read lock first, or use sync.Map for simple cases. However, sync.Map is not a silver bullet; it's optimized for append-only or read-heavy workloads, not for frequent writes or complex updates.

Deadlocks from Lock Ordering

When a goroutine holds multiple locks, acquiring them in different orders can cause deadlocks. For example, goroutine A locks mutex X then Y, while goroutine B locks Y then X. If A holds X and waits for Y, and B holds Y and waits for X, both block forever. This is the classic dining philosophers problem. To prevent this, enforce a consistent lock ordering across all goroutines. Document the order and use static analysis tools to detect violations.

Another pitfall is calling a function that acquires a lock while already holding the same lock. Go's sync.Mutex is not reentrant; a goroutine cannot lock a mutex it already holds—it will deadlock. Use sync.RWMutex carefully: multiple readers can hold the read lock, but a writer blocks all readers. If a reader attempts to acquire a write lock, it will deadlock because it already holds the read lock.

WaitGroup Misuse

sync.WaitGroup is simple but error-prone. Calling Add() after Wait() has started causes a race. Always call Add() before launching the goroutine, not inside it. Also, ensure that Done() is called exactly once per goroutine, even if it panics. Use defer wg.Done() immediately after checking for errors. In complex workflows, consider using errgroup from the golang.org/x/sync package, which combines WaitGroup with error propagation and context cancellation.

Mutexes and WaitGroups require discipline. Use consistent lock ordering, avoid copying mutexes, and prefer higher-level abstractions like errgroup. The next trap involves context cancellation—a powerful feature that is often misapplied.

Context Cancellation Mishandling

Go's context package provides cancellation propagation, timeouts, and request-scoped values. However, many developers ignore context or misuse it, leading to goroutines that continue working after the parent request is cancelled. This wastes resources and can cause cascading failures in distributed systems.

In a typical HTTP server, each request gets a context that is cancelled when the client disconnects or a deadline passes. If a handler spawns goroutines that don't check ctx.Done(), those goroutines may continue processing even after the response is sent. For example, a handler that writes to a database and sends a notification might launch a goroutine for each task. If the client cancels the request, the handler returns immediately, but the goroutines keep running—potentially completing the database write and notification, which might be unwanted side effects.

How to Propagate Context Correctly

Always pass context as the first argument to functions that perform I/O or long-running operations. Within goroutines, select on ctx.Done() and return early when cancelled. For example:

select { case result <- ch: // process result case <-ctx.Done(): return ctx.Err() }

Use context.WithTimeout or context.WithDeadline to set a maximum duration for operations. This prevents goroutines from hanging indefinitely if a downstream service is slow. Also, avoid storing contexts in structs; pass them explicitly. The Go standard library's net/http handler already provides a context via r.Context(), so use that.

Common Context Pitfalls

One mistake is using context.Background() in long-running goroutines that should be cancellable. Always derive from a cancellable context. Another is ignoring the error returned by ctx.Err() after cancellation; it tells you why the context was cancelled (deadline exceeded or cancelled). Also, be careful with context.WithValue: it should only be used for request-scoped data like trace IDs, not for optional parameters. Overusing context values can lead to hidden dependencies and make code harder to test.

In a production incident, a team's background worker used context.Background() for all operations. When the service was shutting down, the worker ignored the shutdown signal because it didn't check a cancellable context. The worker kept processing jobs, causing data inconsistency and delayed shutdown. The fix was to derive a context from the main shutdown context and check for cancellation in the worker loop.

Context cancellation is a first-class feature in Go. Use it consistently to avoid resource waste and ensure graceful shutdown. Next, we'll explore tools and practices for building robust concurrent systems.

Tools and Practices for Robust Concurrency

Building concurrent Go applications requires more than just avoiding traps; you need systematic tooling and practices. The Go ecosystem provides built-in tools like the race detector, pprof, and the trace tool. Integrating these into your development workflow can catch issues early.

The race detector (go run -race) detects data races where multiple goroutines access the same variable without synchronization. It should be run regularly in tests and CI pipelines. However, it has overhead (memory and CPU), so it's not suitable for production use. Use it during development and staging. The race detector only catches races that actually occur during execution, so run it under realistic load.

pprof is for performance profiling. Use it to analyze CPU usage, memory allocation, and goroutine stacks. The net/http/pprof package exposes endpoints for live profiling. For example, /debug/pprof/goroutine?debug=2 dumps full stack traces of all goroutines, making it easy to spot leaks. Integrate pprof into your service as a debug endpoint, but protect it with authentication in production.

The execution tracer (go tool trace) captures a timeline of goroutine scheduling, blocking events, and garbage collections. It's invaluable for understanding latency issues caused by contention or scheduling delays. To use it, add runtime/trace in your code and generate a trace file during a load test. Then analyze the trace in a browser to see where goroutines are blocked.

Design Patterns for Concurrency

Beyond tools, adopt proven patterns. The worker pool pattern limits the number of concurrent goroutines. Use a buffered channel as a job queue and a fixed number of worker goroutines that read from it. This prevents unbounded goroutine growth. The pipeline pattern chains stages connected by channels, each stage running in its own goroutine. Ensure each stage handles cancellation and backpressure. The fan-out, fan-in pattern distributes work across multiple goroutines and collects results. Use sync.WaitGroup or errgroup to coordinate.

For error handling, prefer errgroup which returns the first error and cancels the context for all goroutines. This is cleaner than manually managing channels for errors. Also, consider using semaphore.Weighted from the golang.org/x/sync package to limit concurrency without a fixed pool.

Leverage Go's built-in tools and proven patterns to build reliable concurrent code. In the next section, we'll discuss growth mechanics—how to scale concurrency as your application grows.

Scaling Concurrency: Growth Mechanics and Pitfalls

As your Go application grows, concurrency patterns that worked for a few hundred requests per second may break under thousands. Understanding scalability limits is crucial. The Go scheduler uses M:N threading, where M goroutines are multiplexed onto N OS threads. The default number of threads is GOMAXPROCS (usually the number of CPU cores). If you have many goroutines that are CPU-bound, increasing GOMAXPROCS beyond the number of cores does not improve performance—it may cause contention.

For I/O-bound workloads, goroutines are efficient because they block on I/O without consuming a thread. However, too many blocked goroutines can cause scheduler overhead. The key is to keep the number of actively running goroutines close to GOMAXPROCS. Use worker pools to limit concurrency for CPU-heavy tasks. For I/O, use connection pooling and limit the number of concurrent network calls per request.

Monitoring and Tuning

Monitor goroutine count, memory usage, and scheduler latency. Use pprof to see where time is spent. If you see high sync.Mutex contention, consider using sync.RWMutex or lock-free data structures. The sync.Map type is optimized for read-heavy workloads, but for write-heavy, a custom approach with sharded mutexes may be better.

Another growth trap is ignoring garbage collection (GC) pressure. Goroutines that allocate many short-lived objects increase GC frequency, which pauses all goroutines. Use object pools (sync.Pool) to reuse allocations. Profile memory with pprof to identify allocation-heavy code paths.

In a case study, a team's Go service handled 10,000 requests per second initially. As traffic grew to 50,000, they noticed increasing latency. Profiling revealed that the GC was taking 10% of CPU time. They reduced allocations by pre-allocating slices and using sync.Pool for temporary buffers, which cut GC overhead to 2% and recovered latency.

Scaling concurrency requires ongoing monitoring and tuning. Use worker pools, limit allocations, and profile regularly. Next, we'll address risks and common mistakes that even experienced developers make.

Risks, Pitfalls, and Mitigations

Even with best practices, concurrency bugs can slip through. This section highlights high-risk patterns and how to mitigate them. One risk is the forgot-to-select pattern: using a blocking channel operation without a select that includes a default case or context cancellation. This can cause goroutines to block indefinitely if the channel never receives or sends.

Another risk is channel close confusion. Closing a channel multiple times causes a panic. To avoid this, use a sync.Once to close the channel, or use a dedicated goroutine that closes it exactly once. Also, reading from a closed channel returns the zero value immediately, which can be mistaken for valid data. Use the comma-ok idiom: v, ok := <-ch; check ok to know if the channel is closed.

A third pitfall is deadlock from circular dependencies. If goroutine A sends to channel C1 and goroutine B sends to channel C2, but A also reads from C2 and B reads from C1, they can deadlock if both are blocked on send. This is a classic dining philosophers problem. To mitigate, use a single channel or a lock hierarchy.

Mitigation Strategies

First, always use select with a default case when you don't want to block. Second, use the race detector in CI. Third, write unit tests that simulate concurrent access—use go test -race. Fourth, use code reviews focused on concurrency: check for proper mutex locking, channel lifecycle, and context propagation. Fifth, consider using formal verification tools like go-licenses or static analysis (e.g., go vet).

Finally, have a rollback plan. If a concurrency bug makes it to production, you need to quickly revert or apply a hotfix. Use feature flags to disable problematic code paths. Monitor metrics like goroutine count and latency to detect anomalies early.

Awareness of common pitfalls and systematic mitigation can prevent production incidents. Next, we'll answer frequently asked questions about Go concurrency.

Frequently Asked Questions About Go Concurrency

This section addresses common questions that arise when applying concurrency patterns in Go.

Should I use channels or mutexes?

Channels are preferred for communication between goroutines (passing data). Mutexes are for protecting shared state. Use channels when you have a producer-consumer pattern or need to coordinate goroutines. Use mutexes when multiple goroutines access a shared struct or map. In practice, many solutions use both. A good rule: share memory by communicating, not communicate by sharing memory—but don't force channels where mutexes are simpler.

How do I choose buffer size for a channel?

Buffer size depends on acceptable latency and memory. A buffer of 1 or 0 (unbuffered) forces synchronous communication, which can cause blocking. A large buffer (e.g., 1000) smooths out bursts but uses memory. Start with a small buffer and monitor. If producers often block, increase buffer size. If consumers are overwhelmed and memory grows, reduce buffer or add backpressure.

How do I gracefully shut down a Go service with goroutines?

Use a signal channel (e.g., os.Signal) to catch SIGINT/SIGTERM. When a signal is received, cancel a root context. All goroutines that select on that context will exit. Use sync.WaitGroup to wait for all goroutines to finish before os.Exit. The errgroup package provides a convenient pattern: spawn goroutines that return errors, and the group cancels the context on the first error or when the parent context is cancelled.

What is the best way to limit concurrency?

Use a worker pool with a buffered channel of struct{} as a semaphore. Send a token to the channel before starting work, and receive it after. Alternatively, use semaphore.Weighted from golang.org/x/sync. This limits the number of goroutines running concurrently.

How do I debug a deadlock?

Use the race detector and pprof. The /debug/pprof/goroutine endpoint shows all goroutine stacks. Look for goroutines stuck on chan send, chan receive, or sync.Mutex.Lock. The execution tracer can show the timeline of blocking events. In tests, set a timeout to fail if a deadlock occurs.

These FAQs cover common concerns. Now let's synthesize the key takeaways and next actions.

Synthesis and Next Actions

We've explored four critical concurrency traps: unbounded goroutines, channel misuse, improper synchronization, and context cancellation mishandling. Each trap can stall your Go app, but with awareness and disciplined patterns, you can avoid them. The key is to treat concurrency as a first-class design concern, not an afterthought.

Start by auditing your existing codebase. Check for goroutines without clear exit paths. Use the race detector and pprof to identify issues. Then apply the patterns discussed: worker pools for limiting goroutines, sender-side close for channels, consistent lock ordering, and context propagation everywhere. Integrate these checks into your development workflow—code reviews, CI pipelines, and monitoring dashboards.

Remember that concurrency is a trade-off. More goroutines do not always mean faster execution. Measure performance under realistic load. Use tools like the execution tracer to understand where time is spent. And when in doubt, prefer simpler solutions: sometimes a single-threaded approach with batching is more maintainable and fast enough.

Finally, keep learning. The Go community shares many resources, including the official Go Blog and conference talks. Experiment with new patterns in side projects before adopting them in production. By mastering concurrency, you'll build Go applications that are not only fast but resilient under pressure.

About the Author

This article was prepared by the editorial team for this publication. We focus on practical explanations and update articles when major practices change.

Last reviewed: May 2026

Share this article:

Comments (0)

No comments yet. Be the first to comment!