Back to blog
Aug 20, 2026
7 min read

Common Concurrency Bugs in Go

The concurrency bugs I still see in Go codebases — data races, leaked goroutines, channel mistakes — and how to catch them before production.

Go makes concurrency easy to start and easy to get slightly wrong. The compiler will not save you from a data race, a leaked goroutine, or a channel that nobody is reading anymore.

These are the bugs I still see in reviews, plus the way I usually catch them.


1. Data races on shared state

The classic version:

var count int

for i := 0; i < 10; i++ {
	go func() {
		count++
	}()
}

It looks fine. It is not. count++ is not atomic.

The less obvious version shows up in HTTP handlers:

type Cache struct {
	m map[string]User
}

func (c *Cache) Get(id string) (User, bool) {
	u, ok := c.m[id] // concurrent map read/write = panic or worse
	return u, ok
}

If one goroutine writes to a map while another reads, you can get a panic. You can also get a silent race that only appears under load.

Fix it with a mutex, sync.RWMutex, or don’t share the map. sync.Map is for specific access patterns, not a default replacement.

type Cache struct {
	mu sync.RWMutex
	m  map[string]User
}

func (c *Cache) Get(id string) (User, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	u, ok := c.m[id]
	return u, ok
}

The tool that matters here:

go test -race ./...

Run it in CI. A race that only happens once a day in production is much more expensive than a slightly slower test suite.


2. Loop variable capture (less common now, still not gone)

Before Go 1.22, this was everywhere:

for _, user := range users {
	go func() {
		process(user) // all goroutines could see the last user
	}()
}

Go 1.22 changed loop scoping, so this is safer on recent compilers. I still write the explicit version in code that has to be obvious:

for _, user := range users {
	user := user
	go func() {
		process(user)
	}()
}

or just pass it as an argument:

go func(u User) {
	process(u)
}(user)

If your module still supports older Go versions, do not assume the new loop semantics.


3. Leaked goroutines

This is the one that shows up as a slowly climbing goroutine count.

go func() {
	result := doWork()
	ch <- result // nobody is receiving anymore
}()

Or a worker that never looks at ctx.Done():

go func() {
	for {
		job := <-jobs
		handle(job)
	}
}()

When the request ends, that goroutine is still there. Multiply by traffic and you have a leak.

The rule I use: every goroutine needs a stop condition. Context, a done channel, or a finite input channel that gets closed.

go func() {
	for {
		select {
		case <-ctx.Done():
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			handle(job)
		}
	}
}()

If you start a goroutine in a request handler, it should not outlive that request unless that is an explicit design decision (and then you need a different lifecycle).


4. Sending on a closed channel / closing from the receiver

Only the sender should close a channel. Closing from multiple places, or from the receiving side, is how you get panics.

close(ch)
ch <- v // panic: send on closed channel

If several producers send on the same channel, you need a coordinator to close it — usually a WaitGroup and then one close.

var wg sync.WaitGroup
for i := 0; i < n; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		ch <- work()
	}()
}

go func() {
	wg.Wait()
	close(ch)
}()

Receivers should treat close as “no more data”, not as something they trigger.


5. Unbuffered channels used as a queue

ch := make(chan Event)
ch <- event // this blocks until someone receives

If this is in a request path and the consumer is slow or dead, the request hangs. Sometimes that is what you want (backpressure). Sometimes it is an accidental deadlock.

If you need a buffer, size it for a reason. make(chan Event, 1024) without a plan is just a delayed failure. When the buffer fills, you are back to blocking — or dropping events, if someone added a default case and forgot to mention it.

select {
case ch <- event:
default:
	// this is a dropped event. log it or count it.
}

Dropping work silently is a bug dressed up as resilience.


6. Deadlocks with mutexes and channels

Two mutexes acquired in different orders. A lock held while sending on a channel that the receiver needs the same lock to read. A WaitGroup that never reaches zero because one goroutine is blocked on a channel.

The last one is common:

var wg sync.WaitGroup
ch := make(chan int)
	wg.Add(1)
go func() {
	defer wg.Done()
	ch <- 1
}()
	wg.Wait()
<-ch // never reached

You wait for the goroutine, the goroutine waits to send. Done.

If you use WaitGroup, make sure the thing you are waiting for can finish without you already having waited.


7. Timeouts that do not cancel the work

select {
case res := <-done:
	return res, nil
case <-time.After(2 * time.Second):
	return nil, context.DeadlineExceeded
}

The caller gets a timeout. The work keeps running. If doWork() still holds a DB connection or a lock, you only hid the latency from the client.

Prefer context:

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

return doWork(ctx)

and make doWork actually listen to ctx.Done().

time.After in a hot path also leaks a timer until it fires. Use time.NewTimer if you need a timer in a loop, and stop it.


How I catch these before production

  • go test -race ./... in CI, always
  • goroutine count as a metric. If it only goes up, look for a leak
  • pprof goroutine profile when a service feels stuck
  • bounded worker pools instead of go func() per request for unbounded work
  • context on anything that can block

The race detector does not catch leaked goroutines or logical deadlocks. Those need tests that cancel context and assert that worker functions return, plus metrics in production.

A useful test pattern:

func TestWorkerStopsOnCancel(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})

	go func() {
		defer close(done)
		worker(ctx)
	}()

	cancel()

	select {
	case <-done:
	case <-time.After(time.Second):
		t.Fatal("worker did not stop")
	}
}

If a worker cannot pass this, it will leak in production.


Closing

Concurrency bugs in Go are usually not exotic. They are shared maps, missing stop conditions, and channels used without a clear owner.

The code that holds up in production is the boring kind: one owner for each piece of shared state, one owner for closing a channel, a context that actually cancels work, and tests that run with -race.

If you only add one habit after reading this, add the race detector to CI and start watching the goroutine count. Those two catch a surprising amount of the rest.