Buffered vs unbuffered channels

Buffered channels store values in memory to prevent blocking, while unbuffered channels require immediate synchronization between sender and receiver.

The frozen terminal

You write a function that fetches data from an API. Another function processes that data. You want them to run at the same time so the CPU isn't sitting idle while waiting for the network. You spin up a goroutine for each job and connect them with a channel. The code compiles. You run it. The program freezes instantly. The terminal just sits there. You didn't break the internet. You just hit the default behavior of Go channels.

Direct handoff versus waiting room

Channels are the primary way goroutines talk to each other. An unbuffered channel works like a direct handoff. Imagine two people passing a heavy box. The person holding the box cannot let go until the other person is standing right there with both hands ready. If the receiver isn't ready, the sender waits. If the sender hasn't arrived, the receiver waits. Both sides block until they meet at the exact same moment.

A buffered channel adds a waiting room. Think of a mailbox with a fixed number of slots. You can drop letters inside without waiting for the mail carrier to show up. The sender keeps moving until the mailbox is full. Once every slot is taken, the sender blocks just like the direct handoff. The receiver pulls letters out one by one. The buffer decouples the timing of the two sides.

The capacity you pass to make controls the size of that waiting room. Zero means no waiting room. One or more means a fixed queue. The choice changes how your goroutines coordinate.

Unbuffered channels enforce strict synchronization. Buffered channels absorb timing differences. Pick the coordination pattern that matches your problem.

Minimal example

The syntax difference is a single integer. The behavioral difference is everything.

package main

import "fmt"

// RunUnbuffered demonstrates a direct handoff between two goroutines.
func RunUnbuffered() {
    // Zero capacity means the channel blocks until both sides are ready.
    ch := make(chan string)

    go func() {
        // The sender will pause here until the main goroutine reads.
        ch <- "hello"
        fmt.Println("sent")
    }()

    // The receiver waits for the value to arrive.
    msg := <-ch
    fmt.Println("received:", msg)
}

// RunBuffered demonstrates a channel with a single waiting slot.
func RunBuffered() {
    // Capacity of 1 allows the sender to proceed without an immediate receiver.
    ch := make(chan string, 1)

    go func() {
        // The sender drops the value into the buffer and continues immediately.
        ch <- "world"
        fmt.Println("sent")
    }()

    // The receiver can read later. The value is already waiting.
    msg := <-ch
    fmt.Println("received:", msg)
}

func main() {
    RunUnbuffered()
    RunBuffered()
}

Both programs print the same output. The execution timeline is completely different. In the unbuffered version, the sender and receiver must synchronize at the exact moment of the send operation. In the buffered version, the sender finishes before the receiver even starts reading.

The compiler does not care which pattern you pick. Both are valid Go. The runtime handles the blocking and waking up. Your job is to match the channel type to the coordination requirement.

What the runtime actually does

Go does not use threads for goroutines. It multiplexes thousands of goroutines onto a small number of OS threads. When a goroutine hits a blocking channel operation, the scheduler parks it and switches to another ready goroutine. This is why channels feel lightweight. The blocking is cooperative, not a heavy OS sleep.

An unbuffered channel requires a rendezvous. The scheduler keeps the sender and receiver in a waiting list. When both are ready, the runtime copies the value directly from the sender's stack to the receiver's stack. No intermediate memory allocation happens. The copy is fast and the synchronization is strict.

A buffered channel allocates a ring buffer on the heap. The size is capacity * sizeof(element). The sender copies the value into the next write slot and increments a counter. If the buffer has space, the sender resumes immediately. If the buffer is full, the sender parks and waits for a receiver to free a slot. The receiver reads from the read slot, increments its counter, and wakes a parked sender if the buffer was previously full.

The runtime tracks open and closed states. Closing a channel signals that no more values will arrive. Readers can detect this with the comma-ok idiom or by using a for range loop. The for range loop is the community standard for consuming a channel until it closes. It reads values, handles the zero-value fallback, and exits cleanly when the channel drains and closes.

Memory layout matters when you scale. A buffered channel with capacity ten thousand holds ten thousand copies of your type in contiguous memory. If you store large structs, you waste heap space. If you store pointers, you only store the pointer size. The buffer does not copy the underlying data when you send pointers. It copies the pointer itself.

Channels are synchronization primitives first and data pipes second. Treat them as coordination tools, not as queues you can dump infinite work into.

Realistic example

You are building a background job processor. A web handler accepts a request, validates it, and hands it off to a worker. You want the handler to return quickly. You also want to limit how many jobs run at once so you don't overwhelm the database. A buffered channel gives you a bounded queue. The handler drops the job in the buffer and returns. The worker pulls jobs out and processes them.

package main

import (
    "context"
    "fmt"
    "time"
)

// Job represents a unit of work to be processed asynchronously.
type Job struct {
    ID      string
    Payload string
}

// ProcessJobs reads from a buffered channel and handles each job sequentially.
// ctx controls the lifetime of the worker.
func ProcessJobs(ctx context.Context, jobs <-chan Job) {
    // Range over the channel until it closes or context cancels.
    for job := range jobs {
        // Check context before starting work to respect cancellation.
        select {
        case <-ctx.Done():
            fmt.Println("worker stopping:", ctx.Err())
            return
        default:
            // Simulate database write or heavy computation.
            fmt.Printf("processing job %s: %s\n", job.ID, job.Payload)
            time.Sleep(100 * time.Millisecond)
        }
    }
}

// main sets up a bounded queue and feeds it with a producer goroutine.
func main() {
    // Context carries cancellation signals to all goroutines.
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // Capacity of 5 limits memory usage and backpressures the producer.
    jobQueue := make(chan Job, 5)

    // Start a single worker that consumes from the channel.
    go ProcessJobs(ctx, jobQueue)

    // Producer simulates incoming HTTP requests.
    for i := 0; i < 10; i++ {
        // Send blocks if the buffer is full, naturally throttling the producer.
        jobQueue <- Job{ID: fmt.Sprintf("job-%d", i), Payload: "data"}
    }

    // Close signals that no more jobs will arrive.
    close(jobQueue)

    // Wait for the worker to finish draining the queue.
    // In production, use a sync.WaitGroup or a done channel.
    time.Sleep(2 * time.Second)
}

The buffer size of five acts as a circuit breaker. If the worker falls behind, the producer blocks on the send operation. The handler thread pauses until the worker frees a slot. This prevents unbounded memory growth. The context.Context parameter follows Go convention: it always goes first, it is named ctx, and every long-running function checks it before doing work.

Closing the channel on the sender side is mandatory. The receiver's for range loop depends on it. If you forget to close, the worker blocks forever and leaks a goroutine. The worst goroutine bug is the one that never logs.

Pitfalls and runtime panics

Channels are simple until they aren't. The runtime will not save you from logical errors. It will only enforce the rules you set.

Sending to an unbuffered channel with no ready receiver blocks the goroutine. If every goroutine in your program is blocked on a channel operation, the runtime detects the cycle and panics with fatal error: all goroutines are asleep - deadlock!. This is not a compiler error. The program runs until the scheduler realizes nothing can make progress.

Sending to a closed channel panics immediately. The runtime throws panic: send on closed channel. You can only close a channel from the sender side. Closing a channel twice panics with panic: close of closed channel. The receiver should never call close. If you need to signal completion, close the channel once the producer finishes.

Reading from a closed channel does not panic. It returns the zero value of the element type. You must check the second return value to know if the channel is closed. The for range loop handles this automatically. If you read manually, use the comma-ok idiom: val, ok := <-ch. When ok is false, the channel is closed and drained.

Buffered channels can hide deadlocks. If you send ten values into a buffer of size five, the first five succeed. The sixth send blocks. If no receiver ever runs, the program hangs. The runtime cannot distinguish between a slow consumer and a missing consumer. You must design the cancellation path yourself. Use context.Context to cancel producers, or use select with a timeout to avoid infinite waits.

The compiler will not catch missing close calls. It will not warn you about unbuffered channels that should be buffered. It will not stop you from sending large structs by value. You get what you write. Test the coordination paths under load. Run the race detector with go run -race to catch data races that slip past channel synchronization.

Channels coordinate. They do not manage lifecycles. Pair them with context cancellation and explicit shutdown signals.

Decision matrix

Use an unbuffered channel when you need strict synchronization between two goroutines. Use an unbuffered channel when the producer and consumer must meet at the exact same moment, such as signaling completion or passing a single result. Use a buffered channel when you want to decouple timing and absorb bursts of work. Use a buffered channel when the producer is fast and the consumer is slow, or when you need to backpressure a stream without blocking the caller immediately. Use a larger buffer when you are smoothing out network latency or batching database writes, but keep the capacity bounded to prevent memory leaks. Use sequential code when you don't need concurrency. The simplest thing that works is usually the right thing.

Where to go next