Buffered vs Unbuffered Channel Mistakes

A channel’s capacity is not a performance tuning knob — it changes the synchronization semantics of every send. An unbuffered channel (make(chan T)) makes every send a rendezvous: the sending goroutine blocks until a receiver is ready, and the two goroutines synchronize at that instant. A buffered channel (make(chan T, n)) lets a send complete the moment there is room in the buffer, with no receiver present and no synchronization with any receiver. Most channel bugs trace to one mistaken belief: that adding a buffer is “free” or “just an optimization.” It is not — it removes the back-pressure and the happens-before edge that the unbuffered channel was silently providing (Go Memory Model).

Mental Model

The right mental model is back-pressure. An unbuffered channel couples producer and consumer rate: the producer cannot run ahead of the consumer, because each send waits for a matching receive. A buffered channel of capacity n decouples them by up to n items: the producer may run n items ahead before it feels any resistance. Capacity 0 (unbuffered) and capacity (unbounded — which Go does not offer) are the two extremes; a buffered channel sits at a finite point between.

The second axis is the happens-before edge. The Go Memory Model gives precise guarantees (Go Memory Model):

  • “A send on a channel is synchronized before the completion of the corresponding receive from that channel.” — true for every channel.
  • “A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel.” — true only for unbuffered channels.
  • “The kth receive from a channel with capacity C is synchronized before the completion of the k+Cth send on that channel.” — the generalisation that lets a buffered channel of capacity C act as a counting semaphore: a send can only complete once the receive C ahead of it has finished, so at most C operations are in flight at once.

That second rule is the rendezvous. On an unbuffered channel the receive happens-before the send completes, so when a send returns you know a receiver has taken the value. On a buffered channel you know no such thing — the send may have merely deposited the value into the buffer.

sequenceDiagram
    participant P as Producer
    participant C as Consumer
    Note over P,C: Unbuffered — make(chan T)
    P->>P: ch <- v  (blocks)
    C->>C: <-ch  (blocks)
    Note over P,C: rendezvous: both proceed together
    P-->>C: value handed off, both synchronized

    Note over P,C: Buffered cap=2 — make(chan T, 2)
    P->>P: ch <- a  (returns immediately, buffer=[a])
    P->>P: ch <- b  (returns immediately, buffer=[a,b])
    P->>P: ch <- c  (BLOCKS — buffer full, no receiver yet)

Diagram: on an unbuffered channel the send and receive meet at a single rendezvous point — neither proceeds until both arrive. On a buffered channel the first n sends return without any consumer, and only the n+1-th send blocks. The insight: buffering hides the producer/consumer coupling until the buffer fills, which is exactly when bugs surface.

Mechanical Walk-through

What the runtime actually does

A channel is an hchan struct in src/runtime/chan.go holding (verified field-for-field on master as of the current Go 1.26 source tree): qcount — total data in the queue; dataqsiz — size of the circular queue (zero for unbuffered); buf — pointer to an array of dataqsiz elements; elemsize and elemtype — element layout; closed — a flag set by close; sendx and recvx — the ring-buffer head and tail indices; recvq — list of receive waiters; sendq — list of send waiters; and lock — a mutex protecting every other field. The waiters are sudog values (one per blocked goroutine), each carrying a pointer to the goroutine’s local variable into which (or out of which) the value will be copied.

A send (chansend) proceeds:

  1. If a receiver is already waiting in recvq, dequeue it and copy the value directly into the receiver’s stack slot, then wake it — no buffer touched. This is the unbuffered rendezvous (and also a fast path for a buffered channel that happens to have a waiting receiver — common when the buffer is empty and a consumer is faster than the producer).
  2. Else, if the buffer has room (qcount < dataqsiz), copy the value into buf[sendx], advance sendx, increment qcount, return. The send completes with no receiver involved.
  3. Else, allocate a sudog, park the sending goroutine on sendq, and block until a receiver takes the value.

For an unbuffered channel dataqsiz is 0, so step 2 never applies: a send either rendezvouses (step 1) or blocks (step 3). That is why an unbuffered send blocks without a receiver — there is structurally nowhere to put the value.

A receive (chanrecv) mirrors the send path and adds a noteworthy buffered-channel trick. After locking, it first inspects sendq. If a sender is waiting there, two cases follow: if dataqsiz == 0 (unbuffered) the value is copied directly from the sender’s stack to the receiver — the same rendezvous viewed from the other side; if dataqsiz > 0 (buffered, and the buffer must be full or no sender would be parked), the receiver takes the value at buf[recvx] and the dequeued sender’s value is copied into the same buffer slot, advancing recvx — the buffer head moves forward, the sender’s value becomes the new tail in a single operation. The source comments capture it as: “Found a waiting sender. If buffer is size 0, receive value directly from sender. Otherwise, receive from head of queue and add sender’s value to the tail.” If no sender is waiting and qcount > 0, the receiver copies from the buffer and returns. Otherwise it allocates a sudog, parks on recvq, and blocks. The implication for performance is that a buffered channel under sustained back-pressure does not actually behave like an unbuffered one despite both sender and receiver having to wait — the buffer slot is reused as a one-element handoff per wakeup, avoiding any direct stack-to-stack copy.

A close (closechan) sets closed = 1 under the lock, then walks both wait queues. The source comments are blunt: receivers in recvq are “release all readers” — each gets a zero value (the runtime clears their stack slot via typedmemclr) and sg.success is set to false so the ok return is false; senders in sendq are “release all writers (they will panic)” — they are simply unparked, and on resumption each checks closed and panics. The lock is dropped before goready is called on every released goroutine, so the wakeups happen outside the channel’s critical section.

The capacity-vs-length confusion

cap(ch) is the buffer size, fixed at make time. len(ch) is the current number of buffered, not-yet-received elements. Both are racy to read for control-flow decisions: len(ch) can change the instant after you read it. len and cap on a channel are diagnostics, never the basis for “is there room?” logic — use a select with a default instead.

Closed-channel semantics differ by direction

The spec (Go spec, channel operations) and builtin docs:

  • Sending on a closed channel panics — regardless of buffering.
  • Receiving from a closed channel never blocks: it drains any buffered values first, then yields the zero value with ok == false. So a closed buffered channel still delivers its remaining contents before reporting closed.
  • Closing a channel twice, or closing a nil channel, panics.

Code Examples

Mistake 1: buffer of 1 used as a “free optimization”

// Intent: fire-and-forget a result to a parent that will read it later.
func work() chan int {
	ch := make(chan int, 1)   // capacity 1
	go func() {
		ch <- compute()       // returns immediately — buffer has room
	}()
	return ch
}

This pattern is correct and idiomatic — but only because the capacity exactly matches the number of sends (one). The goroutine sends once, the buffer absorbs it, the goroutine exits cleanly even if the caller never reads. If the goroutine sent twice, the second send would block forever and leak the goroutine. The buffer size here is a deliberate semantic decision (“exactly one result, sender must not block”), not an optimization. Sizing it 2, or 0, would both be bugs of different kinds.

Mistake 2: assuming a buffered send means “delivered”

results := make(chan Result, 100)
 
go producer(results)
 
// ... later, the program decides to shut down ...
close(done)
// At this point up to 100 Results may sit UNCONSUMED in the buffer.
// Those results are silently lost.

Because a buffered send completes without a receiver, “I sent it” does not mean “someone got it.” With an unbuffered channel, each completed send guarantees a receiver took the value (the rendezvous). Code that relies on “send completed ⇒ consumer processed it” is correct on an unbuffered channel and silently wrong on a buffered one. If delivery confirmation matters, either use an unbuffered channel or have the consumer send an explicit ack.

Mistake 3: unbuffered channel send with the receiver gone

func main() {
	ch := make(chan int)      // unbuffered
	go func() {
		time.Sleep(time.Second)
		ch <- 42              // (A)
	}()
	// main returns here without ever receiving.
}

main exits, the program ends, and the goroutine at (A) never gets to run its send to completion — here the process simply terminates. But change the shape so main waits (e.g. on a WaitGroup) without ever receiving, and the send at (A) blocks forever: the goroutine is leaked. This is the Goroutine Leak via Blocked Channel failure mode — an unbuffered send is a commitment that a receiver will appear.

Mistake 4: deadlock from a self-rendezvous

func main() {
	ch := make(chan int)
	ch <- 1                   // fatal error: all goroutines are asleep - deadlock!
	fmt.Println(<-ch)
}

A single goroutine sending on an unbuffered channel and then receiving deadlocks: the send blocks waiting for a receiver, but the only goroutine that could receive is the one stuck on the send. The runtime detects that all goroutines are blocked and panics with fatal error: all goroutines are asleep - deadlock!. Beginners “fix” this by adding a buffer of 1 — make(chan int, 1) — which makes the snippet run, but that is treating the symptom: the real design needs two goroutines, or a buffer chosen for a reason.

Mistake 5: signal channel sized wrong

// A "done" / cancellation signal channel.
done := make(chan struct{})       // unbuffered — CORRECT for close-based signalling
// vs
done := make(chan struct{}, 1)    // buffered — needed if you SEND a signal and
                                  // the sender must not block when no one listens

For a one-shot signal delivered by close(done), an unbuffered channel is right — close never blocks and every receiver (and every select watching it) is woken. But if you send a value as the signal (done <- struct{}{}) and the sender must not block when the receiver may have already exited, a buffer of 1 is the standard fix. Choosing the wrong one gives either a leaked sender (unbuffered, no receiver) or a missed signal.

Failure Modes and Common Misunderstandings

“Bigger buffer = faster.” A larger buffer only helps if producer and consumer rates are bursty and balanced on average; the buffer smooths bursts. If the producer is persistently faster, any finite buffer fills and you are back to the producer’s true rate — the buffer just delayed the back-pressure and inflated memory and latency. If the producer is persistently slower, the buffer is never used. Buffer size should be derived from burst characteristics, not guessed.

“Buffer of N for N producers.” Sizing the buffer to the producer count so “everyone can send once” is a smell — it hides the real question of who drains the channel. The moment producers send a second round, the buffer is full again.

Lost happens-before edge. Code that did data = x; ch <- struct{}{} on an unbuffered channel, relying on the rendezvous so the receiver sees the write to data, can break if someone “optimizes” the channel to buffered. The buffered send still establishes send-before-receive, so the receiver does still see data — that edge holds for all channels. What is lost is the reverse edge (receive-before-send-completes). The subtle bugs come from code that depended on the producer blocking until consumed, not from visibility of the sent data itself.

len(ch) == 0 as “channel is empty, safe to do X”. Racy. Another goroutine can send between your len check and your action. There is no safe non-blocking “is it empty” — use select { case v := <-ch: ...; default: ... }.

Deadlock masked by buffering in tests. A test passes because a buffer of 1 absorbs the single send the test exercises; production sends twice and hangs. Test the real concurrency shape, not a buffered shortcut.

nil channel operations block forever. Sending or receiving on a nil channel blocks permanently (never panics). This is occasionally used deliberately — setting a select case’s channel to nil disables that case — but accidentally leaving a channel nil (declared but never maked) is a silent hang. Distinct from a closed channel, which never blocks.

Alternatives and When to Choose Them

  • Unbuffered channel — default choice. Use when you want strict hand-off semantics, back-pressure, and the rendezvous happens-before edge. The “share by communicating” idiom assumes unbuffered unless there is a reason otherwise (Effective Go).
  • Buffered, capacity = known fixed count — use when a fixed, known number of sends must not block (e.g. capacity 1 for a single fire-and-forget result; capacity = number of workers for a results channel each worker writes once). The buffer size is a correctness argument, written down.
  • Buffered, capacity for burst smoothing — use when measured traffic is bursty and you have sized the buffer from real burst data. Pair it with monitoring of len(ch) to detect persistent backlog.
  • select with default — for non-blocking send/receive (“try-send”, “drop if full”) without relying on len/cap.
  • Closing the channel — for broadcast/fan-out signalling: one close wakes every receiver and every watching select. Use a chan struct{} closed once; never send on it.
  • A bounded queue / semaphore (chan struct{} of capacity N used as tokens) — for limiting concurrency, which is a clearer expression of intent than a buffered data channel.

Production Notes

The “Share Memory By Communicating” blog post and Effective Go both present the unbuffered channel as the baseline and treat buffering as a deliberate departure. A widely repeated piece of code-review guidance in the Go community is: if you cannot state in one sentence why the buffer is exactly size N, it should probably be unbuffered. Buffer sizes like 100 or 1000 with no derivation are a classic review flag — they paper over a producer/consumer rate mismatch that will reappear under load as latency and memory growth instead of as an honest block.

A frequent production incident pattern: a buffered channel masks a slow consumer in staging (low traffic never fills the buffer) and then, under production load, the buffer fills, sends start blocking, and what looked like a “fast async path” suddenly serializes — often presenting as a latency cliff rather than a clean failure. Unbuffered channels surface the same imbalance immediately and visibly during development, which is precisely their value.

Interaction with select

select makes the buffered-vs-unbuffered distinction operationally observable. Each case is a channel operation that may or may not be ready; on a buffered channel “ready to send” means the buffer has room, and “ready to receive” means the buffer has elements or a sender is parked — both decidable without a partner being present. On an unbuffered channel “ready” requires the other side to already be parked; a select over only unbuffered channels with no peers ready will hit its default (or block, if none) regardless of how soon a peer would have arrived.

This produces a real-world bug pattern in metric pipelines and audit-log writers: a select { case ch <- event: default: dropped++ } over a buffered channel is intended as “best-effort, drop on overflow.” If the channel is unbuffered, the default is taken almost every time — because the consumer has to be select-ing on <-ch at the same instant for the case to be ready. The “drop counter” climbs to near 100%, and developers blame the consumer for being slow when in fact the channel was misconfigured. Switching to capacity 1 — even just 1 — completely changes the behaviour: the case becomes ready whenever the buffer is empty, decoupling the producer and consumer’s selection moments.

A second select pattern that depends on buffer behaviour is the try-receive cleanup in shutdown code: for { select { case v := <-ch: process(v); default: return } } drains whatever a producer left behind. On an unbuffered channel this loop sees nothing — a parked sender does not satisfy the receive in a non-blocking select if the runtime does not happen to schedule the wakeup within the select’s evaluation window (the runtime does in fact pair such partners under load, but the program logic must not depend on that timing). The buffer’s contents, by contrast, are deterministically visible to the receiver. Drain-on-shutdown is consequently an idiom that only makes sense over a buffered channel.

nil-channel select case as a deliberate tool

The same runtime rule that makes an accidentally-nil channel hang forever — “a nil channel is never ready for communication” — is leveraged in select to disable a case. A common pattern is a state-machine loop where the next message to send is the loop’s local variable, and the outgoing case’s channel is set to nil whenever there is nothing pending: case out <- next: becomes a no-op until out is reassigned to the real channel and next is loaded. Without this trick a select would either spin (if a default is present) or send a stale next over and over. With it, the case is invisible to the runtime until the program has something to say.

See Also