Skip to content
beginner Phase 9 · Channels, Select, and Channel Patterns

Buffered Channels

Decouple producers and consumers with a FIFO buffer of fixed capacity.

35m
0 problems
Topic Progress 0%

The Ingest Loop That Stalled on Bursts

A log-ingest service reads events from a socket and hands each one to a worker over an unbuffered channel. The socket delivers events in bursts of 30–40 at a time, then goes quiet for a second. With an unbuffered channel, the reader must stop mid-burst and wait for the worker to finish the current event before accepting the next one — the socket buffer fills, the kernel starts dropping, and under load the service loses exactly the events you most wanted to keep.

The fix is a holding area between the two goroutines:

func main() {
	events := make(chan Event, 64) // room for 64 queued events

	go ingest(events)
	for ev := range events {
		process(ev)
	}
}

With capacity 64, behavior changes in one specific way: a send no longer waits for a receiver. It waits only while the buffer is full. The reader can push through an entire 40-event burst without the worker being ready once, because all 40 land in the buffer. The worker then drains at its own pace — until the buffer fills again, at which point the reader blocks and backpressure reaches the socket. That is the whole contract: buffered send completes immediately while len < cap; it blocks at len == cap. Buffered receive blocks only when the buffer is empty.

Compare the full truth table across both channel kinds:

Channel Send blocks when Receive blocks when
unbuffered (cap 0) no receiver waiting no sender waiting
buffered, not full never buffer empty
buffered, full buffer full buffer empty
nil always always

The rendezvous semantics of Channel Fundamentals still exist — they simply trigger at the buffer's edges instead of at every single operation. A value sent into a full buffer still completes only when space exists, and the memory-model guarantee holds: everything written before the send is visible to whoever eventually receives that value.

One detail surprises people who treat buffers as generic queues: cap never changes. A buffered channel is created at its final size; it does not grow like a slice, and there is no automatic reallocation. If 64 slots prove too small under a new traffic shape, the process blocks — which is the point of backpressure, but it means sizing is a design decision, not a default. The next chapter turns that decision into arithmetic.

len, cap, and the Arithmetic of Sizing a Buffer

Buffer sizes should come from measurement, not superstition. The two builtins make the channel observable while the program runs:

ch := make(chan Job, 100)

fmt.Println(cap(ch)) // 100: the fixed buffer size
fmt.Println(len(ch)) // current queue depth: 0 right after make

ch <- Job{}
fmt.Println(len(ch)) // 1

cap is the capacity you chose at make time; len is how many values are waiting right now. Together they give you the two numbers a sizing decision needs. Instrument the pipeline and log the high-water mark:

func monitor(ch chan Job, done <-chan struct{}) {
	var peak int
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			if n := len(ch); n > peak {
				peak = n
				log.Printf("queue depth peak=%d cap=%d", peak, cap(ch))
			}
		case <-done:
			return
		}
	}
}

Run it against a day of real traffic and the sizing rule falls out of two measurements: how large are the bursts, and how long do they last relative to the consumer's rate? If bursts hit 40 events and the worker processes 5 per burst interval, a buffer of 64 absorbs the burst with margin; a buffer of 10 blocks on event 11 and pushes backpressure onto the reader. The failure mode of oversizing is different and milder: the buffer holds values nobody has claimed yet, memory grows, and latency between enqueue and processing stretches — but the program stays correct. Undersizing applies backpressure early; oversizing delays it. Neither is a bug; which one you want is a product decision about dropping versus slowing.

A quick verification trick when you suspect mis-sizing: run the program under burst load and check whether len(ch) ever sits pinned at cap(ch). A pinned depth means the consumer is slower than the sustained arrival rate — no buffer fixes that; the consumer needs help. A depth that spikes and falls means bursts are the problem and the buffer is doing its job.

Two use patterns where the numbers make the design obvious:

jobs := make(chan Job, 1000) // decouple: absorb a burst, one consumer drains later

sem := make(chan struct{}, 8) // capacity IS the concurrency limit (see worker pools)

The second line will reappear in Worker Pools: a buffered channel used purely as a counting semaphore, where cap is the limit and the buffer never holds real work. Same type, opposite role — worth noticing because it shows how much of channel design is choosing a capacity on purpose.

What a Buffer Does Not Fix

The tempting conclusion from the ingest story is that buffering makes programs faster. It does not, and believing it does leads to two anti-patterns.

A buffer smooths bursts; it cannot raise steady-state throughput. If the worker processes 200 events per second and events arrive at 300 per second, any finite buffer merely decides how long the program runs before the sender blocks: at 64 slots and a 100-event/sec deficit, the buffer fills in under a second. The program's eventual behavior is identical to an unbuffered channel — the reader waits — only now with a queue of stale work and higher latency for each event. Fixing sustained overload means faster consumers, more consumers, or dropping work at the edge, all of which happen upstream of the channel. The buffer's legitimate jobs are absorbing variance in arrival rate and decoupling two goroutines whose average rates match but whose moment-to-moment timing does not.

An unbounded queue hides the problem instead of solving it. In a language with a growable channel buffer this would be the fatal temptation; Go's fixed capacity accidentally protects you. Requests like "make the buffer big enough to never block" translate to "grow memory without limit until the process is killed." When a queue must be truly unbounded — a broker absorbing a spike across minutes — that is a deliberate subsystem with its own memory limits and spill policy, not a large integer in a make call.

There is also a subtler cost: a buffer changes who waits for whom. On an unbuffered channel, producer and consumer advance together; a slow consumer immediately holds back the producer, so the two stay in step. Buffer them and the producer can run far ahead, which is what you wanted during a burst — but it also means the consumer now works through a backlog while the producer has moved on. For interactive paths, that backlog is added latency the unbuffered design never had. If your measurement shows len(ch) sitting in the dozens during normal operation rather than spiking only under bursts, you have built a permanent queue between two stages that used to be in sync.

The decision checklist:

  • Arrival rate matches consumption rate but timing is spiky → small buffer sized to the burst.
  • Arrival rate exceeds consumption rate sustainably → more consumers or edge shedding; the buffer only postpones the wall.
  • Producer must never wait for a specific consumer's timing (event notifications, signals) → consider whether an unbuffered rendezvous is actually the correct semantics — and whether closing a channel should carry the signal instead of carrying data.

Quiz

1. On `ch := make(chan int, 5)` after five values have been sent and none received, what does the sixth send do?

Question 1 options

2. Instrumentation shows `len(ch)` pinned at `cap(ch)` for minutes during peak traffic. What does that tell you?

Question 2 options

3. A producer bursts 40 events every second; the consumer averages 40 events per second but works in 200ms batches. Why does `make(chan Event, 64)` help?

Question 3 options

4. A colleague sizes a channel buffer at 1,000,000 and says 'this way the producer never blocks.' What is the main risk?

Question 4 options

Flashcards

Question

State the exact blocking rule for a buffered channel send and receive.

Answer

A send blocks only while the buffer is full (`len == cap`) and completes immediately otherwise; a receive blocks only when the buffer is empty. Unlike the unbuffered rendezvous, producer and consumer do not need to be present at the same moment — only within the buffer's capacity of each other.

Question

What do len(ch) and cap(ch) report on a buffered channel, and when would you log them?

Answer

cap is the fixed buffer size chosen at make time; len is the current queue depth. Log len periodically under load — a depth that spikes then drains means bursts (buffer working as intended), while a depth pinned at cap means the consumer cannot keep up with the sustained rate and more capacity will not help.

Question

Why doesn't a bigger buffer increase throughput between a slow consumer and a fast producer?

Answer

The buffer only stores work that is already waiting; it does not make the consumer process faster. With a sustained rate deficit, any finite buffer fills and the producer blocks anyway — the larger buffer just delays that moment while adding memory use and per-item latency. Sustained overload needs more consumers or rate limiting at the source.

Question

What is the difference between sizing a buffer for burst absorption versus using one to hide backpressure?

Answer

Burst sizing matches capacity to measured spikes between two stages whose average rates agree — the queue drains to near zero between bursts. Hiding backpressure means sizing capacity so the producer never waits despite the consumer being too slow — the queue ratchets upward until memory or latency fails. The first is a timing cushion; the second is a deferred outage.

Question

How can one buffered channel serve as a concurrency limiter without ever carrying real data?

Answer

Create it as `make(chan struct{}, N)` and have each worker send an empty struct to acquire a slot before starting work, then receive to release it. The capacity enforces at most N concurrent workers, and because `struct{}` occupies no memory the buffer holds only tokens. This semaphore pattern underpins worker pool concurrency limits.

Revision Notes

Key Takeaways

  • 1. A buffered send blocks only at `len == cap`; a buffered receive blocks only when empty — everything else returns immediately
  • 2. cap is fixed at make time and never grows; capacity is a design decision you revisit with measurements
  • 3. Buffers absorb burst timing between stages of equal average rate — a sustained consumer deficit still ends in a full buffer
  • 4. Oversizing trades backpressure for memory and latency; undersizing applies backpressure sooner; both stay correct
  • 5. A `chan struct{}` of capacity N doubles as a counting semaphore where cap itself is the concurrency limit

Interview Tips

  • • "How would you size a channel buffer?" — answer with method: measure burst size and consumer rate during the burst, set capacity to cover the gap, then watch `len(ch)` peaks in production to confirm
  • • "Does buffering make a pipeline faster?" — separate smoothing from throughput: buffers decouple timing jitter but cannot fix a consumer that is sustainably too slow; be ready to say where the fix actually belongs
  • • "What happens when a buffered channel fills?" — sender parks, backpressure propagates upstream to the source (socket, HTTP handler), and you decide at that boundary whether to block, reject, or drop
  • • "Give an example of a channel used for something other than data" — the `struct{}` semaphore of capacity N; connect it to bounding concurrency in a worker pool

Cheat Sheet

Buffered Channels

Blocking matrix:

Situation Send Receive
buffer has room, values queued returns immediately returns oldest value
buffer full blocks until a slot frees returns immediately
buffer empty returns immediately (into buffer) blocks until a value arrives

Sizing rule: capacity ≈ size of the arrival burst − what the consumer drains during the burst. Measure with len(ch) peaks under real traffic, not round numbers.

Buffer vs throughput: buffer absorbs variance between stages with equal average rates; it never changes the rates themselves. len(ch) pinned at cap(ch) = consumer deficit, not a capacity problem.

Code template:

jobs := make(chan Job, 1000) // sized to measured bursts

go producer(jobs)
for j := range jobs {
	process(j) // drains at consumer pace
}

// limiter variant: cap is the limit, buffer carries no real data
sem := make(chan struct{}, 8)

Continue Learning