This is a recipe book of advanced channel patterns for Go developers who already know the basics. Each recipe has a tested minimal example, its real output, and a note on when to reach for it. Every program here compiled and ran on Go 1.24.7.
Works with Go 1.21+ (generics are used in a few recipes, so 1.18 is the true floor; verified on Go 1.24.7). If unbuffered versus buffered sends, the close rules, or directional types feel shaky, read Golang channels: buffered, unbuffered and directional first. This article does not re-explain those. It assumes you know them and shows what to build on top. For the scheduler underneath it all, the goroutines tutorial is the concurrency pillar, and the broader complete Go tutorial covers everything before that.
One rule threads through every recipe: a goroutine blocked on a channel with no counterpart never exits. Most of these patterns exist to guarantee an exit.
Recipe 1: a done channel plus context cancellation stops a loop cleanly
A long-running loop should never be the only thing deciding when to quit. Give it a context.Context and select on ctx.Done() in the same place it does work, so a caller can pull the plug. This is the foundation every later recipe leans on.
package main
import (
"context"
"fmt"
"time"
)
// pollInventory checks stock repeatedly until the context is cancelled.
func pollInventory(ctx context.Context, sku string) {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
fmt.Printf("stopping poll for %s: %v\n", sku, ctx.Err())
return
case <-ticker.C:
fmt.Printf("checked stock for %s\n", sku)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 160*time.Millisecond)
defer cancel()
pollInventory(ctx, "SKU-42")
}
Output:
checked stock for SKU-42
checked stock for SKU-42
checked stock for SKU-42
stopping poll for SKU-42: context deadline exceeded
Use when any goroutine runs a loop that outlives a single request. A bare done chan struct{} works too, but context.Context carries deadlines and values across API boundaries, which is why services standardize on it. The full treatment is in the context guide.
Recipe 2: the or-done wrapper reads a channel while respecting cancellation
When you range over a channel you do not own, for v := range in ignores cancellation: it blocks until the producer closes, which a stuck producer never does. The or-done wrapper turns any channel into one that also unblocks on ctx.Done(), so your consumer loop stays simple.
package main
import (
"context"
"fmt"
"time"
)
// orDone reads from in and forwards its values, but stops the moment ctx is
// cancelled instead of blocking on a send or receive that may never complete.
func orDone[T any](ctx context.Context, in <-chan T) <-chan T {
out := make(chan T)
go func() {
defer close(out)
for {
select {
case <-ctx.Done():
return
case v, ok := <-in:
if !ok {
return
}
select {
case out <- v:
case <-ctx.Done():
return
}
}
}
}()
return out
}
func slowFeed(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := 0; ; n++ {
select {
case <-ctx.Done():
return
case out <- n:
time.Sleep(20 * time.Millisecond)
}
}
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
count := 0
for v := range orDone(ctx, slowFeed(ctx)) {
fmt.Println("received", v)
count++
if count == 3 {
cancel()
}
}
fmt.Println("consumer loop exited cleanly")
}
Output:
received 0
received 1
received 2
consumer loop exited cleanly
Use when you consume a channel from code you do not control, or from an infinite producer. The double select (receive, then guard the forward) is the load-bearing detail: without the inner one, or-done itself leaks on the send.
Recipe 3: a tee channel splits one stream into two
Sometimes one value needs two consumers: log every event and also aggregate it, for instance. A tee reads each value once and delivers it to both outputs before reading the next, so a slow branch throttles the fast one instead of dropping data.
// tee splits one input channel into two. Every value is delivered to both
// outputs before the next value is read, so neither branch is starved.
func tee[T any](ctx context.Context, in <-chan T) (<-chan T, <-chan T) {
out1 := make(chan T)
out2 := make(chan T)
go func() {
defer close(out1)
defer close(out2)
for v := range orDone(ctx, in) {
a, b := out1, out2 // local copies for the nil-disable trick
for i := 0; i < 2; i++ {
select {
case <-ctx.Done():
return
case a <- v:
a = nil // this branch got it; disable its case
case b <- v:
b = nil
}
}
}
}()
return out1, out2
}
Driving it with an event source and receiving both copies produces:
audit log: login | metrics: login
audit log: click | metrics: click
audit log: purchase | metrics: purchase
Use when a single stream feeds two independent sinks that must both see every item. The inner loop sets each output to nil after it delivers, which disables that select case (Recipe 5) so the same value cannot go to one branch twice. If you can afford to drop values for a slow consumer, a non-blocking send with default is cheaper than a tee.
Recipe 4: a bridge channel flattens a channel of channels
A producer that hands you a <-chan <-chan T, one inner channel per batch or per page, is awkward to consume. A bridge drains each inner channel in turn and presents a single flat stream, hiding the nesting from the caller.
// bridge consumes a channel of channels and flattens it into one stream,
// draining each inner channel fully before moving to the next.
func bridge[T any](ctx context.Context, chanStream <-chan <-chan T) <-chan T {
out := make(chan T)
go func() {
defer close(out)
for {
var stream <-chan T
select {
case <-ctx.Done():
return
case s, ok := <-chanStream:
if !ok {
return
}
stream = s
}
for v := range stream {
select {
case out <- v:
case <-ctx.Done():
return
}
}
}
}()
return out
}
Feeding it three inner channels (two rows each) yields one ordered stream:
row 1
row 2
row 11
row 12
row 21
row 22
Use when each unit of work produces its own result channel and a downstream stage wants one continuous stream: paginated API reads, per-file parsers, or any producer whose output is itself a sequence of streams. Order is preserved across batches because a bridge fully drains one inner channel before touching the next.
Recipe 5: the nil channel trick disables a select case at runtime
Operations on a nil channel block forever. Inside a select, that means a nil case can never fire, which is how you switch a case off dynamically. This pump forwards work to a sink but freezes deliveries on command by setting its output to nil.
func main() {
work := make(chan int)
sink := make(chan int)
pause := make(chan bool)
go func() {
var out chan int // nil: sending is disabled until we hold a value
var pending int
paused := false
in := work
for {
select {
case v := <-in:
pending = v
in = nil // stop reading until this value is delivered
if !paused {
out = sink
}
case out <- pending:
out = nil // delivered; wait for the next value
in = work
case p := <-pause:
paused = p
if paused {
out = nil // freeze deliveries
} else if in == nil {
out = sink // resume if a value is pending
}
}
}
}()
// producer sends 1,2,3; main reads one, pauses, resumes, reads the rest
}
Output:
got 1
paused; sink is quiet for 100ms
got 2
got 3
Use when a select case must be conditionally live: pausing a stream, retiring a closed input, or gating a send until a value exists. Toggling a channel variable between its real value and nil is clearer and cheaper than restructuring the loop. This idiom shows up constantly once you internalize select.
Recipe 6: a struct{} channel signals with zero bytes
When a channel carries no data, only the fact that an event happened, use chan struct{}. An empty struct occupies zero bytes, and the type states the intent: this is a signal, not a value.
package main
import (
"fmt"
"unsafe"
)
func main() {
ready := make(chan struct{})
go func() {
// ... do setup work ...
close(ready) // broadcast: setup finished
}()
<-ready
fmt.Println("setup complete, proceeding")
fmt.Println("bytes carried per struct{} signal:", unsafe.Sizeof(struct{}{}))
fmt.Println("bytes carried per bool signal: ", unsafe.Sizeof(false))
}
Output:
setup complete, proceeding
bytes carried per struct{} signal: 0
bytes carried per bool signal: 1
Use for every pure signal: done channels, ready flags, semaphores, quit notifications. Prefer struct{} over bool not mainly for the one byte, but because chan bool invites the question “what does true mean versus false?” A struct{} channel has no such ambiguity: the only information is arrival.
Recipe 7: closing a channel broadcasts to N waiters at once
A send delivers to exactly one receiver. A close, by contrast, unblocks every goroutine waiting on that channel simultaneously. That makes a closed struct{} channel the standard way to release many goroutines at a starting line.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
const workers = 4
start := make(chan struct{})
var wg sync.WaitGroup
var mu sync.Mutex
releasedAt := make(map[int]time.Duration)
t0 := time.Now()
for id := 0; id < workers; id++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
<-start // every worker blocks here until the channel closes
mu.Lock()
releasedAt[id] = time.Since(t0).Round(time.Millisecond)
mu.Unlock()
}(id)
}
time.Sleep(50 * time.Millisecond) // let all workers reach <-start
close(start) // one close wakes all four
wg.Wait()
allWithin := true
for _, d := range releasedAt {
if d > 55*time.Millisecond {
allWithin = false
}
}
fmt.Printf("released %d workers with one close\n", len(releasedAt))
fmt.Println("all woke at ~50ms (within tolerance):", allWithin)
}
Output:
released 4 workers with one close
all woke at ~50ms (within tolerance): true
Use when N goroutines must all proceed on one event: a barrier before a load test, a shutdown signal fanned out to every worker, a config-loaded gate. One close replaces N sends and cannot be miscounted. The catch is that a closed channel stays closed, so this is a one-shot broadcast, not a repeatable pulse.
Recipe 8: request/response with an embedded reply channel
To get an answer back from a goroutine that owns some state, put a reply channel inside the request. The server sends the response on that private channel, so each caller receives exactly its own answer with no shared map and no mutex.
package main
import "fmt"
type balanceRequest struct {
account string
reply chan int // the caller's private return path
}
// accountServer owns the balances map. No mutex: only this goroutine touches it.
func accountServer(requests <-chan balanceRequest) {
balances := map[string]int{"alice": 120, "bob": 55}
for req := range requests {
req.reply <- balances[req.account]
}
}
func main() {
requests := make(chan balanceRequest)
go accountServer(requests)
for _, name := range []string{"alice", "bob", "carol"} {
reply := make(chan int)
requests <- balanceRequest{account: name, reply: reply}
fmt.Printf("%s balance: %d\n", name, <-reply)
}
close(requests)
}
Output:
alice balance: 120
bob balance: 55
carol balance: 0
Use when one goroutine owns state that many others need to query or mutate, and you want serialized access without a lock. This is the actor model in miniature: state lives with a single goroutine, and the reply channel routes each answer home. When the state is trivial (a counter, a flag), a sync.Mutex is simpler; reach for this when the operations are richer than a lock naturally expresses.
Recipe 9: a buffered channel is a counting semaphore
A buffered channel of capacity N caps concurrency at N. Acquiring a slot is a send, releasing it is a receive, and once the buffer is full, further acquirers block until someone releases. It is the lightest way to bound how many goroutines hit a resource at once.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
func main() {
const maxConcurrent = 3
sem := make(chan struct{}, maxConcurrent)
var inFlight, peak int64
var wg sync.WaitGroup
for job := 0; job < 12; job++ {
wg.Add(1)
go func(job int) {
defer wg.Done()
sem <- struct{}{} // acquire (blocks if 3 already run)
defer func() { <-sem }() // release
n := atomic.AddInt64(&inFlight, 1)
for { // record the high-water mark
p := atomic.LoadInt64(&peak)
if n <= p || atomic.CompareAndSwapInt64(&peak, p, n) {
break
}
}
time.Sleep(20 * time.Millisecond) // pretend to call a rate-limited API
atomic.AddInt64(&inFlight, -1)
}(job)
}
wg.Wait()
fmt.Printf("ran 12 jobs, peak concurrency never exceeded %d: %v\n",
maxConcurrent, atomic.LoadInt64(&peak) <= maxConcurrent)
fmt.Println("observed peak:", atomic.LoadInt64(&peak))
}
Output:
ran 12 jobs, peak concurrency never exceeded 3: true
observed peak: 3
Use when you want to limit concurrency without a fixed pool of long-lived goroutines: capping outbound API calls, database connections, or open files. When the workload is a steady stream rather than a burst, a dedicated worker pool with a fixed number of goroutines is usually the better structure; a semaphore shines for short-lived, bursty work.
Recipe 10: heartbeats prove a long-running goroutine is alive
A goroutine that goes silent might be finished, or might be wedged. A heartbeat channel emits a tick each cycle so a watchdog can tell the difference: no results and no heartbeats means stuck, which a timeout alone cannot distinguish from slow.
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context) (<-chan struct{}, <-chan int) {
heartbeat := make(chan struct{}, 1)
results := make(chan int)
go func() {
defer close(heartbeat)
defer close(results)
ticker := time.NewTicker(30 * time.Millisecond)
defer ticker.Stop()
for n := 0; ; n++ {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
select { // non-blocking heartbeat: never stall real work
case heartbeat <- struct{}{}:
default:
}
select {
case results <- n * n:
case <-ctx.Done():
return
}
}
}()
return heartbeat, results
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
heartbeat, results := worker(ctx)
received := 0
for received < 3 {
select {
case <-heartbeat:
// liveness confirmed; keep waiting for a result
case r := <-results:
fmt.Println("result:", r)
received++
case <-time.After(60 * time.Millisecond):
fmt.Println("no heartbeat within timeout, worker is stuck")
return
}
}
fmt.Println("worker healthy, got", received, "results")
}
Output:
result: 0
result: 1
result: 4
worker healthy, got 3 results
Use for long-lived workers where “no output yet” is ambiguous: batch jobs, stream processors, anything you would otherwise kill on a naive timeout. The buffered, non-blocking heartbeat send is deliberate: liveness signaling must never block the actual work. Heartbeats also make concurrent tests deterministic, since the test can wait for a tick instead of sleeping.
Layer 3: composing or-done, tee and a semaphore into a stream processor
Real code combines these. Here a document stream is wrapped with or-done, teed into a cheap audit branch and a processing branch, and the processing branch bounds its fetch concurrency with a semaphore. Every stage respects one shared context.
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
docs := documents(ctx, []int{1, 2, 3, 4, 5, 6})
auditCh, workCh := tee(ctx, docs) // tee uses orDone internally
// Branch 1: cheap audit log, counts everything it sees.
var audited int64
auditDone := make(chan struct{})
go func() {
defer close(auditDone)
for range auditCh {
atomic.AddInt64(&audited, 1)
}
}()
// Branch 2: bounded-concurrency processing via a semaphore.
const maxConcurrent = 2
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
var mu sync.Mutex
var lengths []int
for id := range workCh {
wg.Add(1)
go func(id int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
time.Sleep(15 * time.Millisecond) // pretend to fetch and parse
mu.Lock()
lengths = append(lengths, id*100)
mu.Unlock()
}(id)
}
wg.Wait()
<-auditDone
sort.Ints(lengths)
fmt.Println("documents audited:", atomic.LoadInt64(&audited))
fmt.Println("processed lengths:", lengths)
}
Output:
documents audited: 6
processed lengths: [100 200 300 400 500 600]
The audit branch sees all six documents while the work branch processes them two at a time, and both share the cancellation path from ctx. This is the shape of a log tailer, an ETL stage, or an ingestion service. For pipeline-specific variants, the fan-out and fan-in guide builds the pattern in depth.
Common mistakes
Leaking goroutines when a reader abandons a channel. A goroutine that sends on an unbuffered channel blocks forever if the reader walks away. Read one result from a launched search and drop the rest, and every dropped sender parks:
func search(term string) <-chan string {
out := make(chan string) // unbuffered, no cancellation
go func() {
time.Sleep(10 * time.Millisecond)
out <- "hit for " + term // blocks forever if nobody receives
}()
return out
}
Launching 20 of these and reading only one leaves 19 goroutines parked, confirmed by runtime.NumGoroutine():
goroutines still parked: 19
The fix is Recipe 1: give the sender a ctx.Done() (or done) case so an abandoned reader releases it. This is the most common concurrency leak in Go services; the goroutine leaks guide shows how to find them in a pprof profile.
Using chan bool where chan struct{} is clearer. A boolean signal channel makes readers wonder whether false means something. If the only information is “an event occurred,” use chan struct{} (Recipe 6). Reserve chan bool for when both values genuinely carry meaning, like the pause toggle in Recipe 5.
Sending on a channel nobody reads. An unbuffered send with no active receiver, or a buffered send after the buffer fills, blocks. Adding a buffer to make the symptom disappear only moves the block to the next send. Guarantee a receiver exists, or make the send a select with default if dropping is acceptable.
Closing from the receiver side. Only the sender may close, because a send on a closed channel panics. When a receiver closes, the next send crashes the program:
processing 1
processing 2
panic: send on closed channel
goroutine 1 [running]:
main.main()
/tmp/cp/m_recvclose/main.go:19 +0x86
exit status 2
The receiver cannot know the sender is finished. Signal “stop sending” with a separate cancellation channel and let the sender close its own output.
Forgetting that a closed channel broadcasts to all receivers. People expect close to hand off to one waiter the way a send does. It does not: closing wakes every blocked receiver at once. Three goroutines waiting on one done channel all proceed from a single close:
goroutines woken by a single close: 3
That is exactly what makes Recipe 7 work, and exactly what surprises you if you close a channel expecting only one goroutine to notice.
What next
You now have ten channel patterns with the judgment to pick between them. Build outward:
- Fan-out and fan-in in Go: parallel work and pipeline composition that these recipes plug into.
- Worker pools in Go: the fixed-goroutine alternative to the semaphore in Recipe 9.
- Goroutine leaks: how to detect the parked goroutines these patterns are designed to prevent.
- Context in Go: the cancellation values threaded through nearly every recipe here.
If any of the underlying mechanics felt fast, step back to channels: buffered, unbuffered and directional and then the complete Go tutorial; these patterns reward solid fundamentals.