Concurrency is Go's crown jewel - goroutines and channels make concurrent programming feel almost magical. But not every problem belongs in a channel. Many developers fall into the trap of overusing channels or misusing mutexes, resulting in slow, buggy, or unmaintainable code. In this article, we'll demystify when to use channels and when to use mutexes, and why blindly following "Go concurrency patterns" can backfire.

The Misconception

Go's philosophy of "do not communicate by sharing memory; share memory by communicating" is often taken literally. Some gophers try to replace every mutex with a channel, thinking channels are the "Go way" to synchronize everything.

But here's the hard truth: channels are not a free replacement for mutexes. They're great for coordinating goroutines, pipelines, and events - but not always the right tool for protecting shared state.

On the surface, goroutines look more elegant, sure - and they are, in the right context. But trying to funnel all state access through channels, even for a simple counter or map, often leads to:

Example: Consider a simple counter that multiple goroutines increment. Using a channel for this can lead to complex and error-prone code, while a mutex would be straightforward and efficient:

// Using channels to protect a counter
counter := 0
ch := make(chan int)

go func() {
    for val := range ch {
        counter += val
    }
}()

ch <- 1
ch <- 1
close(ch)

Ugh. This works, but it's overkill. A mutex does the same thing with less code and less overhead:

// Using a mutex to protect a counter
var mu sync.Mutex
counter := 0

mu.Lock()
counter++
mu.Unlock()

Channels: For Communication, Not Just Safety

Channels shine when goroutines need to communicate or signal events. They can be used to implement fan-out/fan-in patterns, worker pools, or pipelines:

package main

import (
	"fmt"
)

func main() {
	jobs := make(chan int, 5)
	results := make(chan int, 5)

	// Start 3 workers
	for w := 0; w < 3; w++ {
		go func(id int) {
			for j := range jobs {
				results <- j * 2
			}
		}(w)
	}

	// Send jobs
	for i := 1; i <= 5; i++ {
		jobs <- i
	}
	close(jobs)

	// Collect results
	for i := 0; i < 5; i++ {
		fmt.Println(<-results)
	}
}

Pros:

Cons:

Mutexes: The Right Tool for Shared State

First of all, what is a mutex? A mutex (short for mutual exclusion) is a synchronization primitive that ensures only one goroutine (or thread) can access a piece of shared data at a time. It acts like a lock around critical sections, preventing race conditions when multiple goroutines attempt to read or write the same state concurrently.

A sync.Mutex is designed to guard access to a shared resource. If you just need safe access to a map, counter, or struct, a mutex is often simpler and faster.

Imagine you're maintaining a cache that multiple goroutines need to read and update. A sync.Mutex is the simplest and most efficient way to guard that shared map:

var (
    mu    sync.Mutex
    cache = make(map[string]string)
)

func set(key, value string) {
	mu.Lock()
	defer mu.Unlock()
	cache[key] = value
}

func get(key string) (string, bool) {
	mu.Lock()
	defer mu.Unlock()
	v, ok := cache[key]
	return v, ok
}

Pros:

Cons:

When to Use What

Use Case

Recommended

Protect a counter, map, or struct

Mutex

Implement a worker pool, pipeline, or event queue

Channel

Single producer → single consumer

Channel works nicely

Multiple goroutines updating the same state

Mutex is usually simpler

Rule of thumb: Use mutexes for shared state, channels for communication.

Performance Reality

Benchmarks often surprise Go devs. Simple state mutations protected by mutexes are usually orders of magnitude faster than channel-based approaches because channels involve allocation, scheduling, and copying:

That extra bookkeeping makes channels slower when all you need is to guard a shared variable.

Benchmark: Mutex vs Channel Counter

Let's put this to the test with Go's benchmarking framework:

package main

import (
	"sync"
	"testing"
)

func BenchmarkMutexCounter(b *testing.B) {
	var mu sync.Mutex
	counter := 0

	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			mu.Lock()
			counter++
			mu.Unlock()
		}
	})
}

func BenchmarkChannelCounter(b *testing.B) {
	counter := 0
	ch := make(chan int, 1000)

	// Goroutine that serializes all increments
	go func() {
		for v := range ch {
			counter += v
		}
	}()

	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			ch <- 1
		}
	})

	close(ch)
}

And here’s an example of what the results might look like on a typical laptop (Go 1.23, 8-core CPU):

BenchmarkMutexCounter-8      1000000000   0.8 ns/op
BenchmarkChannelCounter-8     20000000    60 ns/op

Now obviously real-world workloads might slightly differ from synthetic benchmarks (e.g., context switches, OS scheduling etc.) but that's a ~75× performance difference in favor of the mutex!

So why the huge gap? The mutex path is just an atomic operation to acquire/release the lock. The channel path involves synchronization between two goroutines, queue management, and possibly waking up a sleeping goroutine.

This demonstrates why mutexes are the right tool for protecting simple shared state.

Real-World Examples

1. Web Server Request Counting

Imagine you're running an HTTP server and want to count requests:

In production, that's the difference between comfortably handling 100k requests/sec and falling behind at 10k requests/sec.

2. Shared Cache

If multiple goroutines read and write a cache (like map[string]User), a mutex is perfect. Reads and writes happen inline with minimal cost.

With a channel-based "cache manager goroutine", every single read/write becomes a request–response round trip. Instead of O(1) map lookups, you now have O(1) + channel send/receive + scheduling. This introduces latency and makes your cache slower than just hitting the database in some cases.

3. Worker Pool for Task Processing

With a mutex you could have a slice of tasks, protect it with a sync.Mutex, and have multiple goroutines pull work out of it. Each goroutine locks, pops a task, unlocks, processes, and repeats.

But with channels, you can just push tasks into a job channel, spin up N workers, and let them consume concurrently:

jobs := make(chan string, 100)
results := make(chan string, 100)

for w := 0; w < 5; w++ {
    go func(id int) {
        for job := range jobs {
            results <- process(job)
        }
    }(w)
}

for _, j := range []string{"a", "b", "c"} {
    jobs <- j
}
close(jobs)

Here, channels are a natural fit because the problem is work distribution, not just shared memory safety.

Using a mutex would require writing your own coordination logic, which is more error-prone and less readable.

4. Event Notifications / Pub-Sub

With a mutex, you could maintain a slice of subscribers guarded by a mutex. Every time an event happens, you'd lock, loop over subscribers, and call their handler functions. This works, but it mixes synchronization, iteration, and business logic.

Why goroutines + channels are better: channels let you decouple event production from consumption. Each subscriber can listen on its own channel and handle events at its own pace:

subscribers := []chan string{}

func subscribe() chan string {
    ch := make(chan string, 10)
    subscribers = append(subscribers, ch)
    return ch
}

func publish(event string) {
    for _, ch := range subscribers {
        ch <- event
    }
}

Now you can spin up independent goroutines for each subscriber:

sub := subscribe()
go func() {
    for msg := range sub {
        fmt.Println("Received:", msg)
    }
}()

publish("user_signed_in")
publish("user_signed_out")

With goroutines + channels, events flow asynchronously, subscribers don't block each other, and backpressure (buffered/unbuffered channels) is easy to model.

Doing the same with a mutex-based subscriber list quickly becomes messy, especially if one subscriber is slow or blocks.

Other Concurrency Primitives in Go

While mutexes and channels are the most common tools, Go's standard library includes a few other primitives worth knowing:

These tools complement mutexes and channels. For example, you might use a sync.WaitGroup to wait for a batch of goroutines to finish processing before sending a final result on a channel.

Or the counter example with sync/atomic for lock-free incrementing:

package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var counter int64

	// Increment atomically
	atomic.AddInt64(&counter, 1)

	// Read atomically
	value := atomic.LoadInt64(&counter)

	fmt.Println("Counter:", value)
}

This is often the fastest option for simple counters and flags because it avoids lock contention altogether.

If we extend our benchmark from above:

func BenchmarkAtomicCounter(b *testing.B) {
	var counter int64

	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			atomic.AddInt64(&counter, 1)
		}
	})
}

The results would be something like this:

BenchmarkAtomicCounter-8    1000000000   0.3 ns/op
BenchmarkMutexCounter-8     1000000000   0.8 ns/op
BenchmarkChannelCounter-8     20000000   60 ns/op

Notice how atomic operations are ~2–3× faster than mutexes, while channels are orders of magnitude slower for this use case. It's a shame atomic operations are extremely limited: they only work on individual variables and basic types.

Conclusion

Mutexes are perfect for protecting state. Channels shine when you need to coordinate or distribute work/events.

But many Go developers try to force channels into every concurrency problem because they feel more "idiomatic." In reality, channels are not inherently better than mutexes. They're tools for communication, not a silver bullet. It's also important to note that channels and mutexes are not mutually exclusive - sometimes you'll combine them (e.g., worker pool with channel + shared stats protected by mutex). Think of channels as "communication highways" and mutexes as "traffic lights" for shared memory - each has its place.

Overusing channels is a common beginner trap and leads to code that is harder to read, slower to run, and more error-prone — the exact opposite of Go's philosophy of simplicity. Just don't overthink it: mutexes for state, channels for communication.