Skip to content
gohacks.dev
Concurrency

Buffered vs Unbuffered Channels in Go and How to Choose

Compare buffered and unbuffered channels in Go by behavior, throughput and failure modes, with measured numbers and a rule for choosing a buffer size.

7 min read
Summarize in AI
Flat illustration of two options weighed side by side.

make(chan int) or make(chan int, 100): one number decides whether two goroutines meet or merely hand off through a queue. Programmers new to Go often add a buffer to make a program stop hanging and experienced ones remove it for the same reason, from the opposite side. The right choice depends on what you want the channel to guarantee, and the wrong one fails quietly.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article compares the two by behavior, measures what a buffer buys, shows the failure each style hides, and ends with a short rule. It builds on channels explained and belongs to the concurrency material. Everything ran on Go 1.25.6.

The semantic difference

On an unbuffered channel a send and a receive are a rendezvous. Neither completes without the other. That has a consequence beyond timing: when the send returns, the sender knows a receiver has the value. On a buffered channel a send returns when the value is queued, which says nothing about whether anyone has taken it.

main.goGo
package main

import (
	"fmt"
	"time"
)

func main() {
	unbuf := make(chan int)
	buf := make(chan int, 1)

	go func() {
		time.Sleep(50 * time.Millisecond)
		<-unbuf
	}()

	start := time.Now()
	unbuf <- 1
	fmt.Println("unbuffered send returned after about", time.Since(start).Round(50*time.Millisecond))

	start = time.Now()
	buf <- 1
	fmt.Println("buffered send returned after about", time.Since(start).Round(50*time.Millisecond))
}
Text
unbuffered send returned after about 50ms
buffered send returned after about 0s

The unbuffered send waited about 50 ms for the receiver. The buffered one returned at once, with nobody reading. Choose by what the sender needs to know. A progress notification, "tell me when you have started", wants a rendezvous. A log message fired into the void wants a buffer.

What a buffer does to throughput

A buffer does one thing: it lets the producer continue while the consumer is busy, up to the buffer size. It does not change the average rate at which the consumer works. The benchmark below runs a producer and a consumer where the consumer's cost varies in bursts, and compares buffer sizes:

buf_test.goGo
package main

import (
	"fmt"
	"testing"
)

var sink int

func work(n int) int {
	x := n
	for i := 0; i < 200; i++ {
		x = x*31 + i
	}
	return x
}

func run(b *testing.B, size int) {
	ch := make(chan int, size)
	done := make(chan struct{})
	go func() {
		s := 0
		for v := range ch {
			s += work(v)
		}
		sink = s
		close(done)
	}()
	for i := 0; i < b.N; i++ {
		ch <- i
	}
	close(ch)
	<-done
}

func BenchmarkBuffer(b *testing.B) {
	for _, size := range []int{0, 1, 16, 128, 1024} {
		b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { run(b, size) })
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkBuffer/size=0-10         	 3203310	       369.3 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=0-10         	 3226048	       369.6 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=0-10         	 3256543	       372.6 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=1-10         	 3845700	       312.0 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=1-10         	 3847528	       308.9 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=1-10         	 3792152	       309.1 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=16-10        	 4783797	       251.9 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=16-10        	 4719492	       255.2 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=16-10        	 4788074	       249.9 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=128-10       	 4578206	       265.8 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=128-10       	 4426134	       267.3 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=128-10       	 4550888	       266.6 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=1024-10      	 4509336	       264.8 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=1024-10      	 4546509	       266.6 ns/op	       0 B/op	       0 allocs/op
BenchmarkBuffer/size=1024-10      	 4445984	       267.6 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	example.test/lab	22.644s

On an Apple M1 Max the medians were these, per item sent and processed:

Buffer sizens per item
0 (unbuffered)370
1309
16252
128267
1024266

Read them for shape and not for absolute values. Going from no buffer to 16 cuts about a third of the per-item cost, because the producer and consumer no longer need to meet for every item. After that the curve is flat, and 128 and 1024 are no better than 16, slightly worse in fact, since a longer queue means more memory traffic. Once the buffer absorbs the jitter between the two sides, the consumer's own speed is the limit. The flat part of the curve is the reason to be sceptical of "make it bigger" as a tuning move. A very large buffer buys memory use and latency: an item can sit a long time in a long queue, and during shutdown the queue holds work nobody wants.

What a buffer hides

The more serious difference is in failure. An unbuffered channel turns many bugs into immediate, local failures: a missing receiver means the program hangs at that send during the first test. A buffered channel delays the same bug until the buffer fills, which may be never in your tests and quickly in production.

main.goGo
package main

import (
	"fmt"
	"time"
)

func producer(ch chan<- int, n int) {
	for i := 0; i < n; i++ {
		ch <- i
	}
	fmt.Println("producer finished", n, "items")
}

func main() {
	ch := make(chan int, 8) // nobody ever receives from ch

	go producer(ch, 5)
	time.Sleep(20 * time.Millisecond)
	fmt.Println("5 items: all sent without a receiver")

	go producer(ch, 20)
	time.Sleep(20 * time.Millisecond)
	fmt.Println("20 more items: the producer is stuck, not finished")
}
Text
producer finished 5 items
5 items: all sent without a receiver
20 more items: the producer is stuck, not finished

With five items the missing consumer is invisible; the program looks correct. With twenty, the producer blocks forever once the buffer is full, and the goroutine leaks silently. The same logic with an unbuffered channel hangs on the first item. A buffer is not a fix for a missing receiver. It is a delay on the symptom, and the delay is long enough to pass code review and short enough to fail in production (goroutine leaks).

Legitimate reasons to buffer

There are good reasons, and they share a trait: you can state the buffer size from something you know.

The number of senders or results is known. If n goroutines will each send exactly one value, make(chan T, n) guarantees none of them blocks, whatever the receiver does. That is the standard fix for the blocked-sender leak in returning errors from goroutines.

One value, receiver may never come. A buffer of 1 lets a goroutine deposit a result and exit even if the caller timed out and left:

main.goGo
package main

import (
	"fmt"
	"runtime"
	"time"
)

func slowCall() int {
	time.Sleep(50 * time.Millisecond)
	return 42
}

func callWithTimeout(buffered bool) {
	size := 0
	if buffered {
		size = 1
	}
	result := make(chan int, size)
	go func() { result <- slowCall() }()

	select {
	case v := <-result:
		fmt.Println("got", v)
	case <-time.After(10 * time.Millisecond):
		fmt.Println("timed out")
	}
}

func main() {
	before := runtime.NumGoroutine()
	callWithTimeout(false)
	callWithTimeout(true)
	time.Sleep(100 * time.Millisecond)
	fmt.Println("goroutines left behind:", runtime.NumGoroutine()-before)
}
Text
timed out
timed out
goroutines left behind: 1

Both calls time out. The unbuffered one leaves its goroutine blocked forever on result <- ..., since nobody will receive. The buffered one deposits the value and exits. One goroutine is left behind in total, from the unbuffered call. This is the most common correct use of a buffer of size 1, and the pattern behind many timeouts (the timeout pattern).

Absorbing a known burst. An event source that emits a burst of up to 50 messages, consumed more slowly, wants a buffer near 50 so that the source does not stall. Size it from the measured burst, and pair it with a policy for overflow: block, drop the oldest, or drop the newest.

Signal channels. signal.Notify needs a buffered channel, and its documentation says so, because the signal package sends without blocking and would otherwise drop signals if the receiver was not ready at that instant (signals).

Bad reasons to buffer

"It stopped deadlocking." The deadlock was a real design error and the buffer postponed it. "It will be faster." Measure; the gain flattens, as above. "Just in case." An arbitrary number such as 100 or 1000 becomes a latency and memory budget nobody chose. If a producer can outrun a consumer indefinitely, no finite buffer helps; you need backpressure (a bounded wait) or load shedding (load shedding).

Buffers and cancellation

A buffered channel holds values that outlive the goroutines that produced them. When a pipeline is cancelled, items sitting in buffers are discarded, or worse, processed after the caller has gone. If the items are expensive or have side effects, prefer a small buffer and make the consumer check the context before acting on each one. A pipeline in which every stage has a buffer of 100 can carry thousands of in-flight items at shutdown (pipelines and graceful shutdown cover draining).

A rule of thumb

  1. Start with no buffer. Make it work, with the race detector on.
  2. Add a buffer when you can complete this sentence: "it holds up to N items because ___", with the blank being a number you know (senders, results, burst size) or a latency target you measured.
  3. For "one result, receiver may leave", use a buffer of 1.
  4. For a stream with a fast producer and a slow consumer, don't buffer more; bound the work instead.
  5. Look at len(ch) in metrics for any buffered channel in a service. A buffer that is always full is a symptom, and one that is always empty did not need to be there.

The specification covers the rules in its section on channel types, and the Go blog's Go Concurrency Patterns uses both forms.

Taggedchannels
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.