The Or Done and Tee Channel Patterns in Go Explained
Three small channel helpers in Go: or-done to wrap a range loop with cancellation, tee to duplicate a stream, and or to combine several done signals.

The three patterns in this article come from the stock of small channel combinators that Go programmers write sooner or later, most of them catalogued in the book Concurrency in Go. They are not clever. Each solves a recurring annoyance: a for range loop that cannot be cancelled, a stream that two consumers both need, a pile of done signals that all mean "stop". Each is under twenty lines, and each has one place where it goes wrong if you are careless.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article writes them as generic functions on top of context, shows them running, and states the caveats. It belongs to the concurrency patterns cluster, extends the done channel pattern, and relates to pipelines and merging channels. Everything ran on Go 1.25.6.
OrDone: cancellation for a range loop
A for v := range ch loop is the clean way to consume a channel, and it has no way to stop except when the channel closes. Adding cancellation means rewriting the loop as a for { select { ... } } with two cases and a comma-ok check, in every consumer. OrDone wraps the input once so consumers keep the simple loop:
package main
import "context"
// OrDone returns a channel that yields the values of in until in is closed or ctx is done.
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
}The two nested selects are the point. The first waits for either cancellation or the next value. Once a value is in hand, the second makes the send cancellable too, because the consumer might have stopped reading. Missing the second select is the common bug: the goroutine then blocks forever on out <- v after the consumer quits, which is the leak the wrapper was supposed to prevent. The returned channel is closed by the goroutine that sends on it, in a defer, which is the ownership rule from closing channels.
A consumer then reads like the simple case:
for v := range OrDone(ctx, events) {
handle(v) // no select, no ok check; ctx cancellation ends the loop
}The cost is an extra goroutine and an extra channel hand-off per value, around a hundred nanoseconds. For a hot path that matters; for the usual stream of requests or events it does not. If you write a loop once, the explicit select is fine, and OrDone earns its place when the same loop shape appears in many places or when you are building a library of stages.
Tee: one stream, two consumers
Tee reads each value from the input and sends a copy to two outputs. The audit log and the processor both want every event; the live display and the recorder both want every frame. Because the sends happen one after the other, the code must be careful not to let one blocked output block the other from being cancelled:
package main
import "context"
// Tee copies every value from in to both returned channels. It delivers each value to both
// before reading the next, so the slower consumer sets the pace for both.
func Tee[T any](ctx context.Context, in <-chan T) (<-chan T, <-chan T) {
out1, out2 := make(chan T), make(chan T)
go func() {
defer close(out1)
defer close(out2)
for v := range OrDone(ctx, in) {
c1, c2 := out1, out2 // local copies; a nil channel disables its case below
for range 2 {
select {
case <-ctx.Done():
return
case c1 <- v:
c1 = nil // delivered to the first, now wait for the second only
case c2 <- v:
c2 = nil
}
}
}
}()
return out1, out2
}The inner loop runs two rounds of a select over both sends. Whichever consumer is ready first receives the value, and its channel variable is set to nil, which removes that case from later rounds because a send on a nil channel never proceeds (nil channels in select). The second round then waits for the other. This way neither output can block the other from receiving its copy once it is ready, and the value is delivered exactly once to each. A simpler out1 <- v; out2 <- v would force an order, so a stalled first consumer would hold back the second even if it was ready.
Here are both together, with a consumer on each branch:
package main
import (
"context"
"fmt"
"sync"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
source := make(chan int)
go func() {
defer close(source)
for i := 1; i <= 5; i++ {
source <- i
}
}()
audit, work := Tee(ctx, source)
var wg sync.WaitGroup
var auditSum, workSum int
wg.Go(func() {
for v := range audit {
auditSum += v
}
})
wg.Go(func() {
for v := range work {
workSum += v * 10
}
})
wg.Wait()
fmt.Println("audit saw the sum of the originals:", auditSum)
fmt.Println("worker computed the sum of the scaled values:", workSum)
}audit saw the sum of the originals: 15
worker computed the sum of the scaled values: 150Both branches saw all five values: the audit total is 15 and the worker's total is 150. Each sum is written by exactly one goroutine and read after Wait, so there is no race on them.
The big caveat is pace. Tee hands out the next value only after both consumers have taken the current one, so the slower consumer throttles the faster. That is correct when you need the two to stay in step, and unwelcome when one branch is a best-effort logger. Decouple them by giving the slow branch a buffered channel, or a bounded queue that drops, as in the broker in pub/sub with channels. Teeing to more than two outputs is the same code with a slice of channels, and at that point you are building a broadcaster.
Or: many done signals into one
Sometimes you have several independent reasons to stop and want a single channel to select on. Or returns a channel that closes when any of the input channels closes. The textbook version is recursive, using a goroutine tree. A flat version with one goroutine per input is easier to read:
package main
import "sync"
// Or returns a channel that is closed when any of the given channels is closed.
func Or(channels ...<-chan struct{}) <-chan struct{} {
switch len(channels) {
case 0:
return nil // a nil channel never closes
case 1:
return channels[0]
}
merged := make(chan struct{})
var once sync.Once
for _, ch := range channels {
go func() {
select {
case <-ch:
once.Do(func() { close(merged) })
case <-merged: // another input won; stop waiting on this one
}
}()
}
return merged
}The sync.Once guarantees that merged is closed exactly once even if two inputs close at the same moment, since a double close panics. Each helper goroutine also watches merged, so after one input fires the rest exit instead of waiting on channels that may never close. Without that second case, the goroutines for the unclosed inputs would leak.
You will rarely need this in new code, because context composes cancellation for you. A context derived from another with a timeout ends when either the parent is cancelled or the timer fires, and context.AfterFunc and context.WithCancelCause cover most other combinations (AfterFunc and WithoutCancel). Or remains useful for code that predates contexts or that deals with plain chan struct{} signals from other libraries, a shutdown channel from one component and a fatal-error channel from another.
A note on naming: the pattern's original shape is a bare done <-chan struct{} argument, and you will see OrDone(done, in) in books and older code. A context is strictly better in new code because it carries a reason (ctx.Err()), supports deadlines, and is what every standard library call already accepts. Passing ctx.Done() where a done channel is expected converts between the two at no cost.
Using the three together
They compose. A pipeline stage can take OrDone(ctx, in) as its input to be cancellable, hand its output to Tee to feed both a processor and an audit trail, and use Or of two shutdown signals to build the context's cancel trigger. Keep each helper small and tested on its own: a test that feeds a known stream, cancels half way and checks that the output closes and no goroutines remain. The structure of those tests is the same as in merging channels and the goleak article.
For reference, the patterns follow the conventions in the Go blog's pipelines and cancellation article, and the behavior of select with nil channels, which Tee relies on, is specified in the select statement section of the language specification.


