Skip to content
gohacks.dev
Concurrency

When You Should Not Use Goroutines in Your Go Code

Cases where goroutines make Go code slower or harder to maintain: tiny tasks, dependent steps, async APIs, shared state, with a benchmark of the overhead.

6 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

Go makes concurrency cheap enough that the temptation is to use it everywhere. A goroutine costs about two kilobytes of stack to start and something near a microsecond to create and schedule, which is so little that it feels free. It is not free, and a surprising amount of slow, confusing Go code is slow and confusing because it has concurrency nobody needed. The mistake is easy to make precisely because the language removes the friction.

Tested with Go 1.25.6, Apple M1 Max (10 cores), macOS on arm64

This article lists the situations where a goroutine is the wrong tool, backs the main claim with a measurement, and says what to do instead. It is the counterweight to goroutines and to the concurrency-first material in the concurrency patterns cluster, and it follows concurrency versus parallelism. Everything ran on Go 1.25.6 on an Apple M1 Max.

The overhead, measured

The claim "tiny tasks lose" is simple to test. The benchmark sums a thousand integers three ways: a plain loop, one goroutine per element adding into an atomic, and eight goroutines each summing an eighth of the slice:

main_test.goGo
package main

import (
	"sync"
	"sync/atomic"
	"testing"
)

var data = func() []int {
	s := make([]int, 1000)
	for i := range s {
		s[i] = i
	}
	return s
}()

func BenchmarkLoop(b *testing.B) {
	var total int
	for b.Loop() {
		total = 0
		for _, v := range data {
			total += v
		}
	}
	_ = total
}

func BenchmarkGoroutinePerElement(b *testing.B) {
	for b.Loop() {
		var total atomic.Int64
		var wg sync.WaitGroup
		for _, v := range data {
			wg.Go(func() { total.Add(int64(v)) })
		}
		wg.Wait()
	}
}

func BenchmarkEightChunks(b *testing.B) {
	for b.Loop() {
		var total atomic.Int64
		var wg sync.WaitGroup
		chunk := len(data) / 8
		for c := range 8 {
			wg.Go(func() {
				sum := 0
				for _, v := range data[c*chunk : (c+1)*chunk] {
					sum += v
				}
				total.Add(int64(sum))
			})
		}
		wg.Wait()
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkLoop-10                   	 3571243	       335.0 ns/op	       0 B/op	       0 allocs/op
BenchmarkGoroutinePerElement-10    	    3260	    367301 ns/op	   48155 B/op	    2002 allocs/op
BenchmarkEightChunks-10            	  416409	      2845 ns/op	     472 B/op	      18 allocs/op
PASS
ok  	example.test/lab	3.807s

On this machine the plain loop took about 335 ns for the thousand additions. Creating a goroutine per element took roughly 367,000 ns, about 1,100 times longer and 2,002 allocations, because each addition (a fraction of a nanosecond) pays for a goroutine creation and a wake-up. The eight-chunk version, which is how you would do it properly, cost about 2,845 ns with 18 allocations: much better than a goroutine per element, and still more than eight times the loop. The work, a thousand additions, is smaller than the cost of starting eight goroutines and waiting for them.

That sets a rough floor. Concurrency starts to pay when each unit of work takes tens of microseconds or more, so the overhead is a small fraction of it, and then only up to the number of cores for CPU work. For a thousand integers the loop is correct, fast and obvious. For a million integers, eight chunks pay for themselves. For a million items of expensive work they pay a lot. Measure at your real size, not at the size of your example.

Situations where a goroutine is wrong

Tiny tasks. The benchmark above. A goroutine per item for work that takes under a microsecond is slower than the loop, uses more memory, and puts pressure on the scheduler and the garbage collector. Batch the work into chunks, or leave it sequential.

Steps that depend on each other. If step two needs the result of step one, they cannot overlap. Wrapping each in a goroutine and passing results over channels makes a pipeline that processes one item at a time with extra hand-offs. Pipelines gain from concurrency only when several items are in flight and the stages take comparable time (pipelines).

Work that needs a fixed order. Concurrency gives up ordering by default. If the output must match the input order you will add indexes, sorting or sequencing, and the complexity may outweigh the gain. A preallocated result slice with an index per task is the cheap way (an index per goroutine), and for streaming ordered output it is easier not to parallelize.

Heavily shared mutable state. If every task takes the same lock for most of its duration, the tasks run one at a time anyway, and you have added a lock, a data race hazard and scheduling overhead for no speedup (lock contention). Four goroutines contending for one mutex can be slower than one goroutine doing the work.

Code you cannot test deterministically. Concurrency makes ordering a property of the scheduler, and that is a cost in every test you write afterwards. If a feature does not need it, sequential code with fixed behavior is worth a great deal (testing concurrent code).

When memory or file descriptors are the limit. A goroutine per request is fine until each one holds a connection, a buffer or a file. Concurrency without a bound is how a service uses all its memory. If you must be concurrent, bound it (bounded parallelism).

Do not hide a goroutine behind an API

The most common avoidable use is the function that spawns a goroutine and returns a channel, when it could have returned the value:

Go
// Before: the caller must receive, and cannot tell when work started or failed.
func Hash(path string) <-chan result { /* go func() { ... ch <- r }() */ }

// After: synchronous, and the caller adds concurrency if they want it.
func Hash(path string) (string, error) { /* ... */ }

The synchronous version is easier to call, easier to test, handles errors and panics where the caller can see them, and composes: a caller who wants parallelism writes go func() { Hash(p) }() or uses an errgroup, and a caller who doesn't need it pays nothing. The reverse is not true. If the function starts the goroutine for you, the caller cannot take it back. The Go team's code review guidance says this directly in the section on synchronous functions: prefer synchronous functions, because it is easy to add concurrency on top of a synchronous function and hard to remove it from an asynchronous one.

A useful rule is that the caller decides the concurrency, and the callee documents whether it is safe to call concurrently. A library that starts background goroutines on its own, with no way to stop them, is a hazard for every program that imports it.

Where goroutines are exactly right

The flip side deserves stating. Use a goroutine when the work waits: several network calls that are independent, a read that might block, a timer, a signal, a connection per client. The wait is long compared to the cost of a goroutine, and overlapping waits is the entire benefit (timeouts on blocking calls and parallel HTTP requests). Use goroutines when CPU-bound work divides into chunks bigger than the overhead, one per core. And use one when a component has an independent lifetime, a background flusher or a consumer, as long as it has a way to stop and a way to be waited on (starting and stopping goroutines).

A test before you add one

Before writing go, I ask three questions. What is this goroutine waiting for, or what chunk of work is it doing in parallel? How will it stop, and who will wait for it? And what would the code look like if it were sequential, and is that actually too slow, by a measurement? If I cannot answer the first, the goroutine has no job. If I cannot answer the second, it is a leak waiting to be found. If I have not tried the third, I do not know that I need it. The proverb collection puts it as clear is better than clever, and a sequential loop is usually the clearer version.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.