Buffered vs Unbuffered Channels in Go

Use buffered channels for os.Signal to prevent missing signals and fix sigchanyzer vet errors.

The handshake and the mailbox

You are writing a background worker that scrapes a website. It finds a new link and sends it to a channel. The main goroutine receives links and processes them. If the main goroutine is busy, the worker should keep working. You create the channel with make(chan string). The worker sends a link. The main goroutine is stuck. The worker blocks. The scraper stops. You just turned a concurrent system into a sequential one with one line of code.

This happens because unbuffered channels force a handshake. Both sides must be ready. Buffered channels give you a mailbox. The worker drops the link in the box and moves on. The main goroutine picks it up later. The choice between buffered and unbuffered channels changes how your goroutines coordinate. It affects performance, correctness, and how your system handles errors.

How channels block

Channels are communication primitives. They let goroutines send and receive values. The behavior depends on capacity.

An unbuffered channel has zero capacity. make(chan T) creates this. A send blocks until a receive is ready. A receive blocks until a send is ready. This is a rendezvous. The transfer happens atomically. Both goroutines synchronize at the channel. This is useful for coordination. It ensures the receiver is ready before the sender produces data.

A buffered channel has positive capacity. make(chan T, n) creates a buffer of size n. A send blocks only when the buffer is full. A receive blocks only when the buffer is empty. The buffer stores values in memory. The sender writes to the buffer and continues if space exists. The receiver reads from the buffer and continues if data exists. This decouples the sender and receiver. They can run at different speeds.

Unbuffered channels are synchronization. Buffered channels are storage. Confusing the two leads to deadlocks and subtle bugs.

Minimal example

The difference shows up immediately in blocking behavior.

package main

import "fmt"

// UnbufferedDemo shows how an unbuffered channel blocks the sender.
func UnbufferedDemo() {
	// Unbuffered channel requires a receiver to be ready.
	ch := make(chan int)

	// This goroutine sends a value.
	go func() {
		fmt.Println("Sending...")
		ch <- 42 // Blocks here until main receives.
		fmt.Println("Sent!")
	}()

	// Main receives the value.
	// This unblocks the sender.
	val := <-ch
	fmt.Println("Received:", val)
}

// BufferedDemo shows how a buffered channel allows non-blocking sends.
func BufferedDemo() {
	// Buffered channel with capacity 1.
	ch := make(chan int, 1)

	// This goroutine sends a value.
	go func() {
		fmt.Println("Sending...")
		ch <- 42 // Does not block. Buffer has space.
		fmt.Println("Sent!")
	}()

	// Main receives later.
	// The sender already finished.
	val := <-ch
	fmt.Println("Received:", val)
}

func main() {
	UnbufferedDemo()
	BufferedDemo()
}

In UnbufferedDemo, the output order is deterministic. "Sending..." prints, then "Received: 42", then "Sent!". The sender waits for the receiver. In BufferedDemo, "Sending..." and "Sent!" can print before "Received: 42". The sender finishes immediately because the buffer accepts the value.

Run this code. Add a time.Sleep in the main function before receiving in the buffered case. The sender completes while main sleeps. The unbuffered version would deadlock if main sleeps before receiving, because the sender blocks forever.

Unbuffered channels force goroutines to march in step. Buffered channels let them drift apart.

Realistic example: Job queue

A common pattern is a worker pool. A dispatcher sends jobs to a channel. Workers pull jobs and process them. The buffer size determines how the system handles load.

package main

import (
	"fmt"
	"sync"
)

// Job represents a unit of work.
type Job struct {
	ID int
}

// Worker processes jobs from a channel.
// It runs until the channel closes.
func Worker(id int, jobs <-chan Job, wg *sync.WaitGroup) {
	defer wg.Done()
	for job := range jobs {
		// Process the job.
		fmt.Printf("Worker %d processing job %d\n", id, job.ID)
	}
}

// RunPool demonstrates a buffered job queue.
func RunPool() {
	// Buffered channel allows dispatcher to queue jobs.
	// Size 10 absorbs bursts of work.
	jobs := make(chan Job, 10)

	var wg sync.WaitGroup

	// Start workers.
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go Worker(i, jobs, &wg)
	}

	// Dispatcher sends jobs.
	// Sends succeed immediately until buffer fills.
	for i := 1; i <= 20; i++ {
		jobs <- Job{ID: i}
	}

	// Close channel to signal workers to stop.
	close(jobs)

	// Wait for all workers to finish.
	wg.Wait()
}

func main() {
	RunPool()
}

The buffer size of 10 lets the dispatcher push ten jobs without waiting. Workers consume jobs at their own pace. If workers are slow, the buffer fills. Once full, the dispatcher blocks. This provides backpressure. The dispatcher slows down to match the workers.

If you used an unbuffered channel, the dispatcher would block after every job until a worker was ready. This limits the dispatcher's throughput. It forces the dispatcher to wait for a worker even if other workers are idle. Buffered channels smooth out the flow. They allow the fast producer to get ahead slightly.

Convention aside: Receiver names should be short and match the type. (w *Worker) Run() is standard. Avoid (this *Worker) or (self *Worker). The community expects one or two letters. Keep it idiomatic.

Buffering hides latency. It does not fix logic errors.

The os.Signal trap

The most common place to get channels wrong is signal handling. You want to catch SIGINT or SIGTERM to shut down gracefully. The standard pattern uses signal.Notify.

package main

import (
	"fmt"
	"os"
	"os/signal"
)

// GracefulShutdown listens for interrupt signals.
// It uses a buffered channel to capture the signal immediately.
func GracefulShutdown() {
	// Buffer size 1 ensures the signal is captured even if
	// the receiver is not ready.
	sig := make(chan os.Signal, 1)

	// Notify sends signals to the channel.
	signal.Notify(sig, os.Interrupt)

	// Block until a signal arrives.
	<-sig
	fmt.Println("Shutting down...")
}

func main() {
	GracefulShutdown()
}

If you use make(chan os.Signal) without the buffer, the program can hang. signal.Notify registers the channel with the runtime. When a signal arrives, the runtime tries to send to the channel. If the channel is unbuffered, the send blocks until a receive is ready. If your code is stuck in a loop or waiting on another channel, the receive never happens. The signal send blocks. The runtime goroutine handling the signal parks. The program appears frozen. You missed the shutdown signal.

The fix is a buffer of size one. make(chan os.Signal, 1). The runtime sends the signal to the buffer and returns. The value sits in the buffer until your code receives it. The signal is never lost. The sigchanyzer vet check detects unbuffered signal channels. Run go vet to catch this pattern. The tool suggests adding the buffer. Trust the tool. Signal handling is edge-case code that runs rarely. You want it to work the first time.

Convention aside: gofmt formats channel declarations consistently. It puts the capacity in parentheses. make(chan os.Signal, 1) stays readable. Don't fight the formatter. Let gofmt handle indentation and spacing. Focus on logic.

Pitfalls and panics

Channels introduce concurrency bugs. The compiler catches some errors. It misses others.

If you send to an unbuffered channel and no goroutine receives, the program panics at runtime. The compiler cannot prove that a receive exists. It allows the code to compile. The runtime detects the deadlock.

ch := make(chan int)
ch <- 1 // No receiver.

The runtime stops with fatal error: all goroutines are asleep - deadlock!. This error means every goroutine is blocked. The main goroutine is blocked on the send. No other goroutine can unblock it. The scheduler gives up.

Buffered channels delay this panic. If you send to a buffered channel and the buffer fills, the sender blocks. If no receiver ever drains the buffer, the sender blocks forever. The program deadlocks eventually. Buffering does not fix logic errors. It just adds latency before the crash.

Another pitfall is guessing buffer sizes. Developers often pick arbitrary numbers like 100 or 1000. This hides backpressure. If the producer is faster than the consumer, the buffer fills. The producer blocks. If the buffer is too large, the producer keeps pushing data until memory runs out or the system slows down. Start with a buffer of one. Increase only if profiling shows the buffer is a bottleneck. Measure throughput and latency. Don't guess.

Convention aside: Goroutine leaks happen when a goroutine waits on a channel that never closes. If a goroutine sends to a channel and the receiver stops, the sender leaks. Buffering does not prevent leaks. It only delays the block. Always design a cancellation path. Use context.Context to signal shutdown. Pass ctx as the first parameter to functions that might block. Check ctx.Done() in select statements. Context is plumbing. Run it through every long-lived call site.

Decision matrix

Pick the right channel type based on your coordination needs.

Use an unbuffered channel when you need strict synchronization between two goroutines. Use an unbuffered channel when the sender must wait for the receiver to acknowledge receipt. Use an unbuffered channel when you want to limit concurrency by forcing workers to wait for tasks. Use an unbuffered channel when the simplest thing that works is a direct handoff.

Use a buffered channel with size one when a single value might be sent asynchronously and you must not lose it. Use a buffered channel with size one when the sender runs in a signal handler or a callback that cannot block. Use a buffered channel with size one when you need to decouple a one-shot notification from the receiver.

Use a buffered channel with size N when you need to absorb bursts of work. Use a buffered channel with size N when the producer is significantly faster than the consumer and you want to smooth throughput. Use a buffered channel with size N when backpressure is acceptable only after a threshold. Use a buffered channel when you are building a pipeline stage that needs to queue items.

Unbuffered channels are a handshake. Buffered channels are a mailbox. Choose based on whether you need coordination or storage.

Where to go next