AlgoMaster Logo

Buffered Channels

High Priority17 min readUpdated June 6, 2026

A buffered channel is a channel with internal storage. Unlike unbuffered channels, where every send must wait for a matching receive, a buffered channel can hold a fixed number of values that haven't been received yet. That single change reshapes how producers and consumers interact, smooths out short bursts of work, and gives you a built-in bounded queue.

Creating a Buffered Channel

You create a buffered channel by passing a second argument to make. That argument is the capacity, the maximum number of values the channel can hold before any of them are received.

Three sends happen back-to-back, and the first print runs before any receive. With an unbuffered channel, the first send would block until somebody read from the channel. With capacity 3, the channel acts like a three-slot inbox: drop something in, walk away, the next person picks it up later.

The signature is make(chan T, capacity). T is the element type, capacity is a non-negative integer. make(chan T, 0) is the same as make(chan T), an unbuffered channel. Capacity is fixed at creation, there's no resize.

Blocking Rules

The behavior that defines a buffered channel comes down to two short rules.

A send (ch <- v) blocks only when the buffer is full. If there's space, the send writes the value into the next free slot and returns immediately. The sender doesn't wait for a receiver.

A receive (v := <-ch) blocks only when the buffer is empty. If there's at least one value in the buffer, the receive removes it from the front and returns immediately. The receiver doesn't wait for a sender.

Channel stateSend behaviorReceive behavior
Buffer emptyStores value, returnsBlocks until a send arrives
Buffer has 1+ values, not fullStores value, returnsRemoves value, returns
Buffer fullBlocks until a receive frees a slotRemoves value, returns
Closed, buffer non-emptyPanicDrains buffer, returns values
Closed, buffer emptyPanicReturns zero value, ok = false

Compare that to an unbuffered channel, where every send must be paired with a concurrent receive. With a buffer, sender and receiver are partly decoupled. They still have to meet eventually, but they don't have to meet on every single transfer.

The following example shows the send-then-block transition.

The first two sends fly through because the buffer has slots. The third send blocks. The producer prints "trying to send 29.97" but not "sent 29.97" until the consumer wakes up and pulls a value out. From that point on, the two run in lockstep, with the buffer acting as a small shock absorber.

The exact interleaving on your machine may differ, the scheduler can put the consumer's first receive a moment earlier or later. What's guaranteed is that the producer can never get more than capacity values ahead of the consumer.

len and cap on Channels

Channels support two built-in inspection functions, len and cap. They tell you how full the buffer is right now and how big it is in total.

cap is fixed for the life of the channel. len moves up on each send and down on each receive. len(ch) is "how many values are sitting in the channel right now waiting to be received", and cap(ch) is "how many can fit before sends start blocking".

These functions are mostly useful for monitoring and diagnostics, not for control flow. The reason: len and cap give you a snapshot, but in a concurrent program that snapshot is stale by the time you read it. Code like if len(ch) < cap(ch) { ch <- v } looks like a non-blocking send, but another goroutine might fill the slot between the check and the send. The right tool for "try to send, give up if full" is select with a default case.

That said, len and cap are great for logging. If you log len(jobs) every few seconds during a load test, you can see the queue depth grow and shrink in real time. That's how you spot a consumer that can't keep up, long before it crashes.

FIFO Ordering

A buffered channel is a first-in, first-out queue. The order in which a single goroutine sends values is the order another goroutine receives them.

FIFO is straightforward when one goroutine sends and one receives. The interesting question is what happens with multiple senders or multiple receivers.

With multiple senders, the channel preserves the order each sender's values arrive, but it does not impose any order across senders. Sender A's value can land between sender B's first and second value. The values from any one goroutine stay in send order relative to each other.

With multiple receivers, each value goes to exactly one receiver. The channel doesn't broadcast. Whichever receiver wakes up first gets the next value. The order any individual receiver sees is still consistent with the channel's overall FIFO order, just sparse.

The following example has one sender and two receivers.

The pairing of jobs to workers depends on the scheduler. The job numbers each worker pulls are monotonically increasing because the channel is FIFO, but the gaps between them are wherever the other worker grabbed in between.

FIFO is a strong guarantee in Go's channel design. There's no priority queue, no "send this value to the front", no out-of-order pickup. If you need priority, you have to layer it on top, typically with multiple channels and a select.

A 3-Slot Buffer, Visualized

The picture below shows a small producer-consumer pipeline with a 3-slot buffer between them. As the producer pushes values in, the buffer fills up. As the consumer pulls them out, it drains. When the buffer is full, the producer parks. When the buffer is empty, the consumer parks.

The producer side puts values into the back of the buffer. The consumer side takes them out of the front. As long as the buffer has at least one value and at least one free slot, both goroutines run without blocking. The two "parks" on the right are the bookends: full means the producer stops adding, empty means the consumer stops reading.

The buffer is the headroom the producer has to get ahead of the consumer before it has to wait. With cap=1, the producer can be one item ahead. With cap=100, it can be a hundred items ahead. The producer and consumer never have to operate at exactly the same rate, they only have to operate at the same average rate over a window the size of the buffer.

When Buffered Channels Make Sense

Buffered channels solve specific problems. Using them by default is a mistake. They fit when one of the following situations applies.

Smoothing bursty producers. A producer that emits work in short bursts followed by quiet periods pairs well with a buffer that's at least the size of a typical burst. The consumer doesn't need to scale up to match the burst, it just drains the buffer at its own pace. An order-intake service that sees ten orders in one second and zero in the next benefits from a buffer of size ten or so.

The producer finishes its burst in microseconds because it doesn't have to wait for the consumer between sends. It then prints its "free to do other work" line and goes off to do whatever else it has to do. The consumer drains the buffer on its own schedule.

Decoupling rate. When two goroutines run at different but compatible speeds, a buffer lets the faster one stay productive without forcing the slower one to keep up. The classic example is reading from a file (fast) and writing to a network (slower). A small buffer between them lets the read stay one step ahead.

Bounded queue with backpressure. A buffer of size N means "at most N items can be in flight before the producer slows down". That bound is the key property. Without it, an unbounded queue (a slice you keep appending to) lets a fast producer fill memory while the consumer falls behind, and the program eventually crashes with an out-of-memory error. With a bounded queue, the producer feels the slowdown and naturally backs off.

The first two sends fill the buffer. The third one blocks until the consumer finishes job 1. From there, the producer can only get two jobs ahead at any moment. The buffer creates a steady, bounded rhythm.

When Buffered Channels Don't Help

A buffer doesn't add concurrency. It doesn't make the consumer faster. It doesn't fix a real synchronization bug. The cases where a buffer is the wrong tool are just as worth knowing as the cases where it's right.

You need a handoff guarantee. If the sender needs to know that the receiver has actually taken the value before it moves on, a buffered channel doesn't give you that. The sender knows the value made it into the buffer, but it can't tell whether the receiver has picked it up. An unbuffered channel is the right choice here, because every send is a synchronous handshake with a matching receive.

The consumer is permanently slower than the producer. No buffer size fixes that. If the producer averages 1000 events per second and the consumer averages 500, a buffer of any size eventually fills, and the producer ends up blocking at the buffer's rate, which equals the consumer's rate. The buffer just delays the inevitable, while costing you memory and obscuring the actual problem. The fix is faster consumers (fan-out) or fewer producers, not a bigger buffer.

You're using the buffer to "fix" a deadlock. A program that deadlocks with make(chan T) and works with make(chan T, 1000) usually has a structural problem: a goroutine that's supposed to receive isn't running, or a goroutine that's supposed to send doesn't know when to stop. The buffer hides the symptom by giving the sender enough slack that the program finishes before the issue manifests. The bug is still there. Run it long enough, push more load through it, and it deadlocks again.

Here's a classic example.

This deadlocks immediately. The first send blocks because there's no receiver yet, and main is the goroutine that's supposed to receive. A tempting "fix" is to make the channel buffered with capacity 3. That program runs and prints 10 20 30. But the real bug is that there's only one goroutine doing both send and receive. The buffered version works for n=3 and breaks again at n=4. The actual fix is to put the producer in its own goroutine.

A buffer is for absorbing rate mismatches, not for compensating for a missing goroutine. A buffer is not a memory leak excuse. If "make the buffer bigger" makes a bug go away, the real problem is somewhere else, and you'd better find it before it shows up in production with a queue depth alert.

Send on a Closed Buffered Channel

Closing a buffered channel is allowed, and the rules around close are mostly the same as for unbuffered channels, with one important nuance: a closed channel can still have values in its buffer. Receivers drain those values first, then start receiving the zero value with ok = false. Sending to a closed channel always panics, buffered or not.

Closing doesn't discard the buffered values. They stay until they're received. Closing only blocks further sends and tells receivers, via the two-value receive form, that no more values are coming.

The dangerous case is sending on a channel that's already closed.

The buffer had three free slots, but the panic happens anyway because the channel is closed. Capacity is irrelevant once close has been called.

The safe pattern, the same one used for unbuffered channels, is to let the sender own the close. The receiver should never call close on a channel it's reading from, because the sender has no way to know that close happened, and the next send panics.

defer close(out) runs after the producer's last send, which is the only moment when "no more values" is actually true. The receiver just keeps rangeing until the channel is closed and drained. Two values can sit in the buffer at any moment, which lets the producer get slightly ahead, but the close behavior is identical to the unbuffered case.

Capacity Tuning: Picking the Right Size

There's no universal answer to "what capacity should this channel be", but there are some sensible defaults and red flags. The starting point should be the number of pending items you actually expect, not the largest number you can imagine.

Some starting points by workload type:

WorkloadReasonable starting capacity
Smoothing a bursty producerSize of one typical burst
Decoupling reader/writer of similar speed1 to a small constant (4, 8, 16)
Worker pool job queueRoughly the number of workers, or 2x
Pipeline stage between two transforms1 to a small constant
Logging or metrics where loss is OKWhatever fits a few seconds of output

A few signs your capacity is wrong, in either direction:

If len(ch) is at zero almost all the time during normal load, the buffer isn't being used. You either have an unbuffered-equivalent workload (in which case use make(chan T) and be explicit), or the consumer is keeping up easily and the buffer is dead weight.

If len(ch) is at cap(ch) almost all the time during normal load, the consumer can't keep up and the producer is blocking on almost every send. Making the buffer bigger just delays the blocking, it doesn't solve it. The real fix is more consumers, faster consumers, or fewer producers.

If len(ch) swings between zero and cap(ch) over short windows, the buffer is doing exactly what it should, absorbing bursts and giving the consumer time to catch up. That's the healthy state.

The temptation to write make(chan T, 10000) because "more is safer" is one of the most common smells in Go channel code. A bigger buffer doesn't make the program more correct. It just gives a misbehaving producer more rope. If you really need to handle ten thousand pending items, you're probably building a system where channels aren't the right primitive, you want a real durable queue (a message broker, a database table) with the appropriate retention and observability.

Quiz

Buffered Channels Quiz

10 quizzes