Search and navigate

Pages

Start HereRoadmapTutorialsCheatsheetInterview QuestionsResources

Published tutorials

Building a REST API with Go: The Complete TutorialFan-Out Fan-In in Go: Parallel Work with ChannelsFuzz Testing in Go: Finding Bugs AutomaticallyGeneric Functions in Go: Writing Reusable CodeGo Benchmarking: Measuring Performance with testing.BGo Channel Patterns: Advanced Recipes That WorkGo Channels: Communication Between GoroutinesGo Concurrency Patterns: A Practical CatalogueGo Constraints: comparable, Ordered and Custom SetsGo Context: Cancellation, Deadlines, and Request ValuesGo HTTP Client: Timeouts, JSON, and Reliable RequestsGo HTTP Middleware: Build a Production ChainGo Interview Questions: 25 Answers That Show UnderstandingGo Mutex: Protecting Shared State from Race ConditionsGo net/http: Build an HTTP Server from ScratchGo select Statement: Multiplexing Channels ExplainedGo Tools You Need for a Reliable Development LoopGo Tutorial: From First Program to Backend FoundationsGo Type Parameters: Syntax and Constraints ExplainedGo Worker Pool Pattern: Bounded Concurrency at ScaleGo's Type System Deep Dive: Static Typing Done RightGolang Compilation and Execution ExplainedGolang Developer Salary 2026: US, UK and RemoteGolang File Structure for Modules and ApplicationsGolang Hello World and Your First Go ProgramGolang IDEs: Choose an Editor for Your WorkflowGolang Interfaces: What They Are and How to Use ThemGoroutine Leaks in Go: How to Detect and Prevent ThemGoroutines in Go: Concurrency Without LeaksHow to Download and Install Golang SafelyHow to Learn Golang with a Focused Study PlanIntegration Testing in Go: Testing Real BoundariesJSON in Go: Encoding, Decoding, and ValidationLearn Golang with a Practical Beginner CourseMocking in Go: Test Doubles Without Magicsync.WaitGroup in Go: Coordinating Multiple GoroutinesTable-Driven Tests in Go: The Idiomatic WayTesting in Go: Table Tests, HTTP, Fakes, and CoverageWhat Is Go and How Does a Go Program Work?Why Learn Golang and What Is Go Used For?Writing Unit Tests in Go: A Practical Guide
Open full search and filters →

Go Channel Patterns: Advanced Recipes That Work

Advanced Go channel patterns with tested code and real output: or-done, tee, bridge, semaphores, heartbeats, and exactly when to reach for each.

Standard-library lessonGo requirement: Go 1.21+How tutorials are checked
Concurrency · Lesson 10Saved in this browser. No account required.
A channel stream passing through a switch and branching into two controlled outputs
Advanced channel patterns control how streams split, merge, pause, and stop. Image: Golang Tutorial

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.