The race for the first value
You are building a search endpoint. You have three sources of truth: a local cache that returns instantly, a primary database that takes 50ms, and a backup replica that takes 200ms. You want to return the first result that arrives. If the cache hits, you don't care about the database. If the database beats the cache, you ignore the replica. You need a way to listen to multiple channels and react to the winner. This is the Or-Channel pattern.
The pattern takes several input channels and produces a single output channel. The output channel delivers the first value sent by any of the inputs. It races the sources and discards the losers. This is a fundamental building block for fallbacks, timeouts, and fan-out strategies.
The scanner analogy
Think of a radio scanner monitoring multiple police channels. The scanner doesn't care which channel talks first. It just outputs the first voice it hears and ignores the rest until it resets. In Go, channels are the wires. The select statement is the scanner. The Or-Channel pattern wraps this behavior so you can treat multiple sources as a single stream. You feed several channels into a function, and it hands you back one channel that delivers the first value from any of the inputs.
The key insight is that select blocks until at least one case is ready. If you put multiple receives in a select, Go waits for the first one to complete and executes that branch. The Or pattern automates this by spawning a goroutine to run the select and forwarding the result.
Minimal implementation
Here is the core implementation for two channels. It spawns a goroutine to race the inputs and sends the winner to an output channel.
// Or2 returns a channel that delivers the first value from c1 or c2.
// The function returns immediately; the goroutine handles the race.
func Or2[T any](c1, c2 <-chan T) <-chan T {
// Unbuffered channel ensures the caller blocks until a value arrives.
// The goroutine will block on send until the caller receives.
out := make(chan T)
// Goroutine runs the select loop in the background.
// Without this, the caller would block inside the select.
go func() {
// Select waits for either channel to send a value.
// It picks one arbitrarily if both are ready.
select {
case v := <-c1:
out <- v
case v := <-c2:
out <- v
}
}()
return out
}
The function creates out and launches the goroutine. It returns out immediately. The caller can now receive from out. Inside the goroutine, the select statement blocks. It watches both c1 and c2. The Go runtime puts the goroutine to sleep until one of those channels has data. When c1 sends a value, the runtime wakes the goroutine, executes the case v := <-c1 branch, and sends the value to out. The caller receives it. The goroutine finishes and exits.
Buffer the output channel to size 1. A leaked goroutine is a silent memory drain.
What the runtime does
When the goroutine hits the select, the compiler generates code that checks the readiness of each channel. If no channel is ready, the runtime adds the goroutine to a wait queue associated with each channel. The goroutine is suspended. It consumes no CPU.
When a sender writes to c1, the runtime scans the wait queue for c1. It finds the suspended goroutine and wakes it up. The goroutine resumes execution, reads the value, and sends it to out. If out is unbuffered and the caller hasn't started receiving yet, the goroutine blocks on out <- v. This is safe. The goroutine waits until the caller reads. Once the read happens, the value transfers, and the goroutine exits.
If you make out unbuffered and the caller drops the channel without receiving, the goroutine blocks forever on out <- v. The goroutine leaks. The program hangs or runs out of memory. Always buffer the output channel to size 1, or close it explicitly if you manage the lifecycle.
Select is fair. Ties are random. Don't bet on order.
Realistic variadic pattern
Real code rarely has exactly two channels. You need to support an arbitrary number of sources. You cannot write a select with a dynamic number of cases using standard syntax. The solution is reflect.Select. It lets you build the cases programmatically.
Here is the production-ready version. It handles any number of channels using reflection to build the select cases dynamically.
import "reflect"
// Or returns a channel that delivers the first value from any source.
// It uses reflection to support a variable number of input channels.
func Or[T any](sources ...<-chan T) <-chan T {
// Buffer size 1 prevents the goroutine from blocking on send.
// The goroutine can send the result and exit immediately.
out := make(chan T, 1)
go func() {
// Build select cases from the input channels.
// Each case represents one potential sender.
cases := make([]reflect.SelectCase, len(sources))
for i, src := range sources {
cases[i] = reflect.SelectCase{
Dir: reflect.SelectRecv,
Chan: src,
}
}
// reflect.Select blocks until one case is ready.
// It returns the index of the chosen case and the received value.
chosen, recv, _ := reflect.Select(cases)
out <- recv.Interface().(T)
}()
return out
}
The function builds a slice of reflect.SelectCase structs. Each struct points to one input channel. reflect.Select takes this slice and performs the race. It returns the index of the winning case and the received value. The goroutine casts the value back to T and sends it to out. The buffer allows the goroutine to send and exit without waiting for the caller.
Reflection is a tool. Use it when the type system forces your hand.
In realistic code, you almost always pass a context.Context to cancel the race. Context is plumbing. Run it through every long-lived call site. The convention is that context.Context is the first parameter, named ctx. Functions that take a context should respect cancellation. If the context is cancelled, the goroutine should stop waiting and exit. You can achieve this by adding a case for ctx.Done() to the select, or by using a helper that wraps the sources with context awareness.
Adding a deadline
Often you want an Or with a deadline. You race the sources against a timer. If no source responds in time, you get a fallback. This is a common variation of the pattern.
Here is how you add a deadline to the race. It combines the Or pattern with a time-based fallback.
import "time"
// OrWithTimeout returns the first value or waits until the deadline.
// It combines the Or pattern with a time-based fallback.
func OrWithTimeout[T any](sources <-chan T, timeout time.Duration) <-chan T {
// Buffer size 1 allows the goroutine to send and exit.
// This prevents leaks if the caller never receives.
out := make(chan T, 1)
go func() {
select {
case v := <-sources:
out <- v
case <-time.After(timeout):
// Send zero value on timeout.
// The caller must check if the value is valid.
var zero T
out <- zero
}
}()
return out
}
The time.After function returns a channel that receives the current time after the duration elapses. The select races the source against the timer. If the timer wins, the goroutine sends the zero value of T.
The timeout pattern has a subtle flaw. If the timeout fires, the function sends the zero value of T. If a real source also sends the zero value, the caller cannot tell the difference. You lose information. In production code, wrap the result in a struct that carries an error or a source identifier. Or return a channel of a result type that includes a boolean flag. The Or pattern delivers data; it does not validate it.
Pitfalls and errors
If you forget to import reflect in the variadic version, the compiler rejects the program with undefined: reflect. Import the package or use a fixed-argument version.
The compiler rejects code where you try to select on a non-channel type with invalid operation: select on non-channel. Ensure all cases in the select are channel operations.
If you pass a channel that is never closed and never sends, the select blocks forever. The goroutine hangs. This is a logical error, not a compile error. Always ensure that channels have a sender or a cancellation path. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.
select is fair. If multiple cases are ready, Go picks one pseudo-randomly. You cannot predict which channel wins a tie. Design your code so the result is the same regardless of the winner, or use a priority mechanism outside the select. If you need priority, check the high-priority channel first in a sequential if before entering the select.
Don't pass a *string. Strings are already cheap to pass by value. The same applies to small structs and integers. Pass by value unless you need mutation or the type is large.
Decision matrix
Use the Or-Channel pattern when you have multiple independent sources and you only need the first result. Use a select with a timeout when you need to abort the race if no source responds in time. Use a fan-out/fan-in pattern when you need to aggregate results from all sources, not just the first one. Use sequential checks when the sources are fast and blocking is acceptable; concurrency adds complexity that isn't always worth it. Use a mutex with a shared variable when you need to coordinate state updates rather than streaming values.
Trust gofmt. Argue logic, not formatting.