AlgoMaster Logo

Unbuffered Channels

High Priority16 min readUpdated June 6, 2026

An unbuffered channel is a channel with zero capacity. Every send on it pauses until another goroutine performs a matching receive, and every receive pauses until another goroutine performs a matching send. That forced meeting in the middle, called a rendezvous, is what makes unbuffered channels the default tool for synchronization in Go.

What make(chan T) Actually Gives You

When you write make(chan int) without a second argument, Go gives you a channel with capacity zero. There is no internal slot for a value to sit in. The value being sent has nowhere to wait, so the sending goroutine has to wait too, holding the value, until a receiver shows up to take it. The same thing happens in reverse: a receiver that arrives first has nothing to grab, so it parks until a sender appears.

Look at the order. The sender prints "about to send" first, then sits there, holding the value. Nothing else from the sender happens until main runs <-prices. Only after main receives does the sender's Println("sender: send completed") finally run. The send statement was a single point of synchronization between the two goroutines.

If you switch the line to prices := make(chan float64, 1), the buffered version, the sender prints both lines immediately and main reads the value later. That difference is the whole point: an unbuffered channel forces the two goroutines to meet; a buffered channel lets the sender drop the value and walk away.

The Rendezvous: Send and Receive Are One Event

The phrase that captures what unbuffered channels do is "rendezvous". The send and the receive happen at the same logical point in time. Neither side can move past the channel operation alone; they cross it together.

The diagram shows the order of operations, but the important detail is what happens at the bottom: both sides resume from the channel operation at the same point. The Go memory model gives you a guarantee about this. The send on an unbuffered channel happens-before the receive completes, and everything that happened in the sender before the send is visible to the receiver after the receive. That's a real synchronization primitive, not just a queue.

In practice this means you can use an unbuffered channel to safely hand off data that was prepared by another goroutine, with no extra mutex. The act of receiving on the channel is enough to establish that the data is ready to read.

The receiver in main sees ID == 7001 and Total == 49.99 even though those fields were written in a different goroutine. Without the channel, you'd need a mutex (or some other synchronization) to make those writes visible. The channel send is doing two jobs at once: it transfers the pointer, and it publishes the writes that came before.

When the Buffer Hides Timing Bugs

A common temptation when something blocks is to "fix" it by adding a buffer. make(chan int, 1) is the lazy reflex. Sometimes that's the right answer, but often it's masking a real problem.

Here's a typical case. A program wants to know that a worker has started before it does something else.

The buffer of size 1 made the design look correct on paper. The bug, the missing send in the worker, is silent until you watch the program timing.

Make the same channel unbuffered and the design itself forces you to think about who waits for whom.

The worker can't proceed until main is listening, and main can't proceed until the worker signals. That's exactly the property you wanted in the first place. The buffered version was lying to you.

The rule of thumb most Go authors follow: start with unbuffered, add a buffer only when you have a specific reason (smoothing bursty traffic, decoupling producer and consumer rates, breaking a known deadlock). If you can't say in a sentence why the buffer needs to exist, it shouldn't.

Practical Patterns

Several patterns come up frequently with unbuffered channels.

Pattern 1: Single-Value Handoff

You have one goroutine that produces a value, and one that consumes it. An unbuffered channel does the handoff and the synchronization in one step.

The receiver gets exactly one value, the sender exits after one send. This is the simplest possible use of a channel, and it's surprisingly common in production code for things like "compute this thing in the background and give me the answer".

Pattern 2: "I'm Done" Signal With a Value

A goroutine does some work and reports back the result, with the channel doubling as the "I'm finished" signal.

This is a one-shot producer-consumer. The unbuffered channel guarantees that main only continues past <-totalCh once the worker has actually finished computing. There's no race window where main could read a half-computed value, because there's no shared value, the worker hands the finished number over.

Pattern 3: Request and Response Over Channels

A worker can take a request and reply on a per-request reply channel. The reply channel is unbuffered so the worker knows the requester is listening.

Each request carries its own reply channel. The worker can serve many callers without the callers stepping on each other. The unbuffered reply channel guarantees that main blocks until the worker has answered, so the loop is naturally sequential from the caller's perspective even though the work runs in another goroutine.

Pattern 4: Synchronization Signal With No Value

Sometimes you don't have a value to pass; you only need to say "go ahead now" or "I'm done now". The idiom is chan struct{}. The empty struct takes zero bytes and makes it obvious from the type that you're using the channel only for its timing.

This is a tiny version of waiting for two goroutines without sync.WaitGroup. Each goroutine closes its own channel when finished; main receives from each, which unblocks as soon as the close happens. For waiting on many goroutines, WaitGroup is cleaner. For canceling a goroutine, the done-channel pattern builds on this same idea.

Deadlocks on Unbuffered Channels

The blocking behavior that makes unbuffered channels useful is also what makes them deadlock. If a send has no receiver, or a receive has no sender, and no other goroutine can step in to fix that, the program is stuck. Go's runtime detects the simple case where every goroutine is blocked and panics with a clear error.

Deadlock 1: Send With No Receiver in the Same Goroutine

This is the bug almost everyone hits at least once. You try to send and receive on the same unbuffered channel from a single goroutine.

main is the only goroutine, and it parks on the send. No one else exists to receive, so the program is permanently stuck. Go notices and crashes. The fix is to put one side of the channel in another goroutine.

Now the send happens in goroutine 2 and the receive in goroutine 1 (main). They rendezvous, the value transfers, and both goroutines proceed.

Deadlock 2: Receiver Outlives the Sender

The opposite case. A goroutine waits forever for a value that no one will ever send.

main parks on the receive. There is no other goroutine, nothing will ever send. The runtime detects it and fails. The fix is either to send from another goroutine, or to close the channel from somewhere (which makes the receive return the zero value).

Deadlock 3: Two Goroutines, Each Waiting on the Other

A subtler one. Two goroutines each try to receive before sending.

Both spawned goroutines are blocked on a receive, and main is blocked on the sleep (or actually, after the sleep, it would exit and you'd see "done", but the runtime tends to detect the deadlock before that). Even if main got past the sleep, the two workers would still be stuck on each other. Fix: have one of them send first, so the other one has something to receive.

Deadlock 4: Forgetting That a nil Channel Blocks Forever

A subtle trap. An uninitialized channel variable has the zero value nil. Sending to or receiving from a nil channel blocks forever, with no runtime error.

The fix is the obvious one: use make.

Nil channels actually have a legitimate use case in select statements, where they let you disable a case. For now, treat a forgotten make as a deadlock waiting to happen.

The diagram captures the only thing the runtime really cares about: is there any other goroutine that could possibly unblock this one? If yes, park and wait. If no, fail loudly. That second branch is what makes Go's deadlock detection so quick to catch the simple cases.

Unbuffered vs Buffered, Side by Side

The contrast worth pinning down is when each is appropriate.

PropertyUnbuffered (make(chan T))Buffered (make(chan T, n))
Capacity0n
Send blocks whenAlways, until a receiver arrivesBuffer is full
Receive blocks whenNo sender is waitingBuffer is empty
SynchronizationStrict: send and receive rendezvousLoose: sender may run ahead
Happens-beforeSend happens-before receive completesk-th send happens-before k-th receive completes (with buffer caveats)
Best forSignaling, handoffs, "are you ready?"Smoothing bursts, decoupling producer/consumer rates
RiskEasy to deadlock if you forget a goroutineEasy to hide timing bugs

The short version: unbuffered channels are for synchronization, buffered channels are for throughput. If your reason for using a channel is "I need to coordinate two goroutines at this point", unbuffered is almost always right. If your reason is "this producer is bursty and I don't want to slow it down on each send", consider a buffer.

Every buffered channel should come with a comment or context that explains the buffer size. If a channel just says make(chan int, 100) with no justification, the size was probably picked by feel. An unbuffered channel doesn't need that justification because there's nothing to pick.

A Worked Example: A Tiny Order Confirmation Service

Putting the pieces together. Two goroutines hand off work through unbuffered channels: one validates an order, one charges the customer, and main collects the final result.

Three unbuffered channels form a pipeline. Each stage of the pipeline has to wait for the next stage to be ready before it can hand off its work, which means the pipeline never gets ahead of itself. There's no risk of the validator queuing up dozens of orders that the charger can't keep up with. If the charger is slow, the validator naturally slows down to match.

This back-pressure property is a direct consequence of the channels being unbuffered. Add a buffer of size 100 to validated, and the validator can run far ahead, charging up 100 orders in memory that the charger hasn't even started on. That might be what you want; it also might be how a memory leak starts. The unbuffered version is the safe default.

Quiz

Unbuffered Channels Quiz

10 quizzes