- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You hit a deadlock in a Go test. The fix that made it green was adding
a number: make(chan int) became make(chan int, 1). The test passed.
You moved on.
That number is doing more than you think. In Go, the buffer size on a
channel is not a performance dial. It changes the contract between the
sender and the receiver. Get it wrong and you either serialize code
that was meant to run in parallel, or you paper over a deadlock that
comes back under load with a different face.
Here is what the size actually controls, and how to pick it on purpose
instead of by trial and error.
An unbuffered channel is a handoff, not a queue
make(chan int) gives you an unbuffered channel. A send on it does not
complete until a receiver is ready to take the value. The two
goroutines meet at the same instant. Go calls this a rendezvous.
func main() {
ch := make(chan int)
go func() {
fmt.Println("sending")
ch <- 42
fmt.Println("sent") // runs AFTER main receives
}()
time.Sleep(100 * time.Millisecond)
fmt.Println("receiving")
v := <-ch
fmt.Println("got", v)
}
The "sent" line does not print until main runs <-ch. The send
blocks the whole time. That is the point of an unbuffered channel: it
synchronizes two goroutines. The value moving across is almost a side
effect. The real product is the guarantee that both goroutines were at
that line at the same moment.
Reach for unbuffered when you want that guarantee. A worker that must
confirm it received a job before you continue. A signal that a stage
finished. Anywhere "the other side definitely has this" matters more
than throughput.
A buffered channel decouples sender from receiver
make(chan int, 3) gives you room for three values in flight. A send
completes immediately as long as the buffer has space. The receiver
does not have to be ready.
func main() {
ch := make(chan int, 3)
ch <- 1 // returns right away
ch <- 2 // returns right away
ch <- 3 // returns right away
// ch <- 4 would block: buffer is full
fmt.Println(len(ch), cap(ch)) // 3 3
fmt.Println(<-ch) // 1
}
Now the sender and receiver run at different speeds without waiting on
each other, up to the buffer's capacity. The buffer is a queue. cap
is how deep it goes; len is how full it is right now.
This is what you want when a producer runs in bursts and a consumer
drains steadily, and you can absorb the gap. A logging channel, a batch
of jobs handed to a worker pool, results collected from N goroutines
that all finish around the same time.
The buffer size is a queue depth, so pick it like one
Once you decide a channel is buffered, the number is a real design
decision, not a magic value. Ask what happens when the buffer fills.
A full buffered channel behaves exactly like an unbuffered one: the
next send blocks until a receiver frees a slot. So the buffer buys you
slack, not immunity. If the consumer is permanently slower than the
producer, every buffer size fills eventually and the producer blocks
anyway. You just delayed the moment.
That gives you a rule for the size. Set it to the largest burst you
expect to absorb before the consumer catches up, not to some round
number that felt safe.
// N producers each send exactly one result.
// Sizing to N means no producer ever blocks
// waiting for the collector.
func fanIn(work []Job) []Result {
out := make(chan Result, len(work))
var wg sync.WaitGroup
for _, j := range work {
wg.Add(1)
go func(j Job) {
defer wg.Done()
out <- process(j) // never blocks
}(j)
}
wg.Wait()
close(out)
results := make([]Result, 0, len(work))
for r := range out {
results = append(results, r)
}
return results
}
Here len(work) is the correct size because you know exactly how many
values will ever be in flight. Every producer sends once and exits. No
producer waits on the collector, so no producer leaks if the collector
is slow to start. The size came from the problem, not from a guess.
If you had written make(chan Result) (unbuffered) here, each producer
goroutine would block on its send until the loop below started
draining. They would still finish, but they would sit parked instead of
exiting, and the code reads as if it is streaming when it is not.
A buffer of 1 is a signal, not a fix
The buffer of 1 deserves its own section because it shows up in two
completely different situations, and only one of them is legitimate.
The legitimate use is a signal channel where you must not block the
sender even if nobody is listening yet. The os/signal package
documents exactly this:
func main() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
fmt.Println("shutting down")
}
signal.Notify sends non-blocking. If the channel were unbuffered and
your goroutine was not sitting on <-sigs at the exact moment the
signal arrived, the notification would be dropped. The buffer of 1
holds one pending signal so it is not lost. The size is 1 because you
only care that a signal arrived, not how many.
The same shape appears in a "latest value wins" pattern, where you
combine a buffer of 1 with a non-blocking send:
// Keep only the most recent status. Never block
// the producer.
func publish(ch chan Status, s Status) {
select {
case ch <- s:
default:
// buffer full: drop, or drain and replace
select {
case <-ch:
default:
}
ch <- s
}
}
That is deliberate. The buffer of 1 plus select/default says "I
want the freshest value and I accept losing stale ones."
The illegitimate use is the one from the top of this post: you added
, 1 to make a deadlock go away. That is a signal too, just a
different one. It is telling you the send and the receive were not
lined up, and instead of fixing the ordering you gave the send one free
pass. Under light load it works because there is only ever one value in
flight. Under real load a second send arrives before the first is
drained, the buffer is full, and the deadlock is back. Now it is harder
to reproduce because it depends on timing.
When you find yourself reaching for a buffer of 1, ask which case you
are in. Signal channel that must not drop? Fine. Deadlock silencer?
Stop and fix the synchronization.
The deadlock shapes to recognize
Most channel deadlocks in Go are one of a few shapes. Learn them and
the fix is obvious instead of trial-and-error.
Send with no receiver. The classic. An unbuffered send with nobody
on the other end blocks forever. If it is the only goroutine, the
runtime catches it:
func main() {
ch := make(chan int)
ch <- 1 // all goroutines are asleep - deadlock
}
fatal error: all goroutines are asleep - deadlock. The runtime can
only detect this when every goroutine is blocked. If one other
goroutine is alive but not receiving, you get a silent leak instead of
a fatal error, which is worse.
Receiver waiting on a channel that never closes. A for v := range loop runs until the channel is closed. Forget the
chclose and the
loop parks forever after the last value.
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
// missing close(ch)
for v := range ch {
fmt.Println(v)
} // prints 1, 2, then blocks forever
}
The fix is close(ch) after the last send, done by the sender, never
the receiver.
Wrong buffer size hiding a real ordering bug. The one this whole
post is about. A buffer big enough for the test masks a producer that
outruns the consumer. It comes back when the burst exceeds the buffer.
For the first shape, unbuffered plus a real receiver on another
goroutine fixes it. For the second, close the channel. For the third,
the buffer size is not the fix at all; the fix is making the consumer
keep up or bounding the producer.
How to choose, in one pass
When you write a channel, answer two questions in order.
First: do the sender and receiver need to meet? If yes, unbuffered.
The blocking is the feature. You get a guarantee that both sides
reached that point.
Second: if they do not need to meet, how many values can be in flight
before the sender should feel back-pressure? That number is your
buffer size, and it should come from the workload: the number of
producers, the burst you can absorb, the "one signal is enough" of a
notification channel. Not from a round number, and never from a
deadlock you wanted to disappear.
The buffer only controls how far the two sides are allowed to drift
apart before Go makes them wait for each other again. Speed is a side
effect, never the reason you reach for one.
Channels are the part of Go where "it compiles and the test passes"
and "it is correct under load" drift furthest apart, and the buffer
size sits right on that seam. The Complete Guide to Go Programming
digs into the runtime side of this — how the scheduler parks and wakes
goroutines on a send, what the buffer actually costs, and why the
rendezvous is the primitive everything else is built on. Hexagonal
Architecture in Go is about the other half: keeping this concurrency
at the right boundary so channel decisions live in one place instead of
leaking through every layer of a service.

Top comments (0)