Ch. 25 · Go

Go Buffered vs Unbuffered Channels: Interview Guide

When a Go channel send blocks, what buffering changes, who closes a channel, and how select, nil channels and timeouts fit together.

~8 min readintermediateupdated Oct 6, 2026

“What is the difference between a buffered and an unbuffered channel?” sounds like a definition question, but interviewers use it to open a chain: what happens on a send to a closed channel, who should close, how do you time out a receive, and why did this code leak goroutines? Channel semantics are small enough to memorise and subtle enough that most concurrency bugs in Go code reviews trace back to them.

The goal is to explain channels as a blocking contract between goroutines, not as a queue. Once you can say exactly when each operation blocks, the rest follows. Examples target Go 1.23.

Before you start

You should be comfortable starting goroutines and waiting for them with sync.WaitGroup, and know that a goroutine blocked forever is never garbage-collected. It helps to know the zero value of a channel type is nil, and that make(chan T, n) creates one with buffer capacity n (zero when omitted).

The short answer

An unbuffered channel has no storage, so a send blocks until another goroutine receives, and the handoff synchronises the two goroutines. A buffered channel stores up to n values; sends block only when it is full and receives only when it is empty. Closing a channel tells receivers that no more values are coming: they drain what is buffered, then receive zero values with ok == false. Only the sender closes, because sending on a closed channel panics.

How it works

At run time a channel is a pointer to a runtime.hchan structure: a lock, an optional circular buffer, and two wait queues of parked goroutines, one for blocked senders and one for blocked receivers. A send takes the lock and then:

  1. If a receiver is waiting, copies the value directly to it and wakes it.
  2. Otherwise, if the buffer has space, copies the value into the buffer.
  3. Otherwise, parks the sender on the send queue until a receiver arrives.

Receives mirror this. An unbuffered channel is the case where step 2 never applies, so every send meets a receiver in person.

That meeting is also a memory model guarantee. A send is synchronized before the corresponding receive completes, so anything the sender wrote before sending is visible to the receiver afterwards. For unbuffered channels the reverse also holds: the receive is synchronized before the send completes, which is why a channel can act as a “done” signal both ways.

The behaviour of every operation fits in one table:

Operation nil channel open channel closed channel
send ch <- v blocks forever blocks until receiver or buffer space panic
receive <-ch blocks forever blocks until a value is available buffered values, then zero value, ok == false
close(ch) panic succeeds panic
done := make(chan struct{}) // struct{} carries no data, only the event
var result int

go func() {
	result = compute()
	close(done) // closing wakes every receiver, now and in the future
}()

<-done
fmt.Println(result) // safe: the close is synchronized before this receive returns
go

Step-by-step walkthrough

Step 1: Use an unbuffered channel for a handoff

When the sender must know the value was taken, use no buffer. A request-response style worker is a good example: the caller is sure the worker has the job once the send returns.

jobs := make(chan Job)
go worker(jobs)

jobs <- Job{ID: 1} // returns only once worker has received it
go

If nobody ever receives, the sender blocks forever. In main with no other goroutines, the runtime aborts with fatal error: all goroutines are asleep - deadlock!; inside a busy server you just get a leaked goroutine, which is worse because nothing tells you.

Step 2: Use a buffer to decouple, or to bound

A buffered channel lets a producer run ahead by up to n items. Two idioms use this:

// A queue that absorbs short bursts
events := make(chan Event, 100)

// A semaphore that caps concurrency at 10
sem := make(chan struct{}, 10)
for _, u := range urls {
	sem <- struct{}{}        // acquire: blocks when 10 are in flight
	go func() {
		defer func() { <-sem }() // release
		fetch(u)
	}()
}
go

A buffer does not make a slow consumer faster. If the consumer handles 50 events per second and the producer sends 60, a buffer of 100 just delays blocking by ten seconds. Choose the size for a reason you can state: burst size, number of senders, or a concurrency limit.

Step 3: Close from the sender and range on the receiver

func produce(n int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out) // the only goroutine that sends is the one that closes
		for i := range n {
			out <- i
		}
	}()
	return out
}

for v := range produce(3) { // loop ends after the close
	fmt.Println(v)
}
go

Returning <-chan int makes the direction part of the type: the caller cannot send or close. With several senders, none of them can safely close alone. Start a coordinator goroutine that waits on a WaitGroup covering all senders and then closes once. You do not need to close channels just to free memory; an unreachable channel is collected whether or not it was closed.

Step 4: Bound every wait with select

select {
case res := <-results:
	return res, nil
case <-ctx.Done():
	return Result{}, ctx.Err()
case <-time.After(2 * time.Second):
	return Result{}, errors.New("timed out")
}
go

select blocks until one case can proceed. If several are ready, it chooses pseudo-randomly, so case order is not a priority. A default case makes it non-blocking, which is useful for best-effort sends such as dropping a metric rather than stalling. In Go 1.23 and later (with a matching go line), a time.After timer that is no longer referenced can be garbage-collected before it fires, so using it inside a loop no longer piles up live timers; a context is still the better tool for request deadlines.

Step 5: Disable a case with a nil channel

When merging two streams, a closed input is always ready (it yields zero values), so a naive loop spins. Setting the variable to nil turns the case off:

for a != nil || b != nil {
	select {
	case v, ok := <-a:
		if !ok { a = nil; continue }
		out <- v
	case v, ok := <-b:
		if !ok { b = nil; continue }
		out <- v
	}
}
close(out)
go

Worked scenario

A search service queries three replicas and returns the first answer. It looks correct and passes its tests, but the goroutine count in production climbs by two for every request until the pod runs out of memory.

func First(ctx context.Context, q string, replicas []Search) (Result, error) {
	results := make(chan Result) // unbuffered
	for _, search := range replicas {
		go func() { results <- search(ctx, q) }()
	}
	select {
	case r := <-results:
		return r, nil
	case <-ctx.Done():
		return Result{}, ctx.Err()
	}
}
go

The function receives exactly one value. The two slower replicas finish later and try to send on an unbuffered channel that nobody will ever read again, so they block forever. On a timeout, all three leak.

The fix gives every sender a slot so its send can always complete, and cancels the losers’ work:

func First(ctx context.Context, q string, replicas []Search) (Result, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel() // tells slower replicas to stop once we have an answer

	results := make(chan Result, len(replicas)) // every send succeeds without a reader
	for _, search := range replicas {
		go func() { results <- search(ctx, q) }()
	}
	select {
	case r := <-results:
		return r, nil
	case <-ctx.Done():
		return Result{}, ctx.Err()
	}
}
go

The buffered channel and its leftover values are garbage-collected once the goroutines exit. This “buffer sized to the number of senders” pattern is the standard cure for single-use result channels.

Common mistake

  • Closing from the receiver to say “stop sending”. The next send panics. Signal stop with a separate done channel or a context instead.
  • Adding a buffer to make a deadlock go away. It often only moves the deadlock to the point where the buffer fills.
  • Using len(ch) to decide whether a send will block. Another goroutine can change it between the check and the send; use select with default.
  • Expecting select to prefer the first case. Ready cases are chosen at random.
  • Believing every channel must be closed. Close only when receivers need to learn the stream ended.

Verify the behavior

Test the leak fix by checking that no goroutines are left behind, using go.uber.org/goleak:

func TestFirstDoesNotLeak(t *testing.T) {
	defer goleak.VerifyNone(t)

	slow := func(ctx context.Context, q string) Result {
		select {
		case <-time.After(50 * time.Millisecond):
		case <-ctx.Done():
		}
		return Result{Source: "slow"}
	}
	fast := func(ctx context.Context, q string) Result { return Result{Source: "fast"} }

	got, err := First(context.Background(), "go", []Search{slow, fast, slow})
	if err != nil || got.Source != "fast" {
		t.Fatalf("got %v, %v", got, err)
	}
}
go

VerifyNone retries for a short period and fails the test if extra goroutines remain. Against the unbuffered version, the two slow senders stay blocked and the test fails with their stack traces. Run with go test -race -run TestFirstDoesNotLeak.

Follow-up questions

How do you give one channel priority? Use a nested select: first try the high-priority channel with a default, and only then fall into a select over both.

Why chan struct{} for signals? An empty struct has zero size, which documents that only the event matters, not a value.

Can you tell whether a channel is closed without receiving? No. A receive with comma-ok is the only check, and it consumes a value if one is buffered.

Is a channel faster than a mutex? For guarding a small piece of shared state, usually not; a channel operation takes a lock internally and may park goroutines. Use channels to pass ownership and coordinate.

Interview exercise

A producer runs for i := range 5 { ch <- i; fmt.Println("sent", i) } with ch := make(chan int, 2). The consumer starts one second later and receives everything. What is printed during that first second? What changes if the channel is unbuffered?

Answer and reasoning

During the first second, sent 0 and sent 1 are printed. The two sends fill the buffer, and the third send blocks because the buffer is full and no receiver is waiting, so sent 2 does not appear until the consumer starts. After that, the remaining lines appear as the consumer frees slots. With an unbuffered channel, nothing is printed during the first second: the very first send blocks until the consumer receives. The reasoning shows you can predict blocking from capacity alone, which is what you need when sizing buffers or debugging a stuck pipeline.

Continue learning

Practise with the Go interview questions and the Go MCQs. Related notes: Go worker pools and goroutine leaks, Go context cancellation and the producer-consumer problem. Primary sources: the channel types section of the Go specification, the Go memory model and the Go blog post on pipelines and cancellation.

More in Go

esc