How to Implement the Tee Channel Pattern in Go
You have a stream of temperature readings arriving from a sensor. One part of your system needs to save every reading to a database. Another part needs to check if the temperature exceeds a threshold and send an alert. A third part just wants to display the current value on a dashboard. You can't read the sensor channel three times. A channel is a pipe: once you pull a value out, it's gone. You need a way to split that single stream into three independent streams so each consumer gets the full picture without blocking the others.
The Tee Channel pattern solves this by introducing a middleman goroutine. This goroutine reads from the source channel and fans out the values to multiple destination channels. Think of it like a broadcast antenna. The antenna receives one signal and rebroadcasts it to multiple receivers. Each receiver gets the signal independently. If one receiver is slow, it doesn't block the antenna or the other receivers, provided the channels are buffered correctly. The pattern is essentially a fan-out operation: one input, many outputs, all synchronized to the source's lifecycle.
The core implementation
Here's the simplest tee function. It uses generics to handle any type and accepts a variadic list of destination channels.
// Tee reads from src and writes to all dests until src closes.
// It closes all dests when src closes.
func tee[T any](src <-chan T, dests ...chan<- T) {
// Ensure all destinations close when the source stream ends.
defer func() {
for _, d := range dests {
close(d)
}
}()
// Range over the source to handle values and detect closure.
for v := range src {
// Send the value to every destination channel.
for _, d := range dests {
d <- v
}
}
}
The function signature uses [T any] so the tee works with integers, strings, structs, or pointers without rewriting the logic. The src parameter is a receive-only channel <-chan T, which enforces that the tee only reads from the source. The dests parameter is a variadic list of send-only channels chan<- T, which enforces that the tee only writes to the destinations. This directionality is a Go convention that makes the data flow explicit and prevents accidental misuse.
How the tee goroutine behaves
The tee runs as a goroutine. It loops over src using range. This blocks until a value arrives or the source closes. When a value arrives, the inner loop iterates over dests and sends the value to each one. The send operation d <- v blocks until the destination channel accepts the value. If the destination is unbuffered, the send blocks until a receiver is ready. If the destination is buffered, the send blocks only when the buffer is full.
This blocking behavior has a critical implication. The tee goroutine blocks until all destinations accept the value. If one consumer is slow, the tee blocks, which prevents it from reading the next value from the source. If the source channel is unbuffered, the producer blocks waiting for the tee to read. A slow consumer can cascade backpressure all the way to the producer. Buffering the destination channels breaks this chain. The tee can send to the buffer and continue reading from the source, even if the consumer is slow. The trade-off is memory. Larger buffers absorb bursts but consume more RAM.
The defer block ensures that when src closes, the loop exits and all destination channels close. Closing a channel is the standard way to broadcast "no more data" in Go. The consumers range over their destination channels and exit their loops when the channel closes. This lifecycle coupling is essential. If the tee doesn't close the destinations, the consumers hang forever, leaking goroutines. The tee owns the lifecycle of the destination channels. Only the tee should close them.
A realistic fan-out scenario
Here's a complete program that uses the tee pattern to distribute log messages to two sinks. The code is split to show the setup and the consumers separately.
The main function creates the channels, starts the tee, and launches the producer.
package main
import (
"fmt"
"sync"
)
// Tee reads from src and writes to all dests until src closes.
func tee[T any](src <-chan T, dests ...chan<- T) {
defer func() {
for _, d := range dests {
close(d)
}
}()
for v := range src {
for _, d := range dests {
d <- v
}
}
}
func main() {
// Source channel for log messages.
logs := make(chan string)
// Buffered destinations to decouple tee from slow consumers.
stdout := make(chan string, 10)
file := make(chan string, 10)
// Start the tee to fan out logs.
go tee(logs, stdout, file)
// Producer sends logs and closes the source.
for i := 0; i < 5; i++ {
logs <- fmt.Sprintf("Log entry %d", i)
}
close(logs)
// Wait for consumers to finish processing.
// Consumers are defined below.
}
The destination channels are buffered to size 10. This allows the producer to send up to 10 messages without blocking, even if the consumers haven't started yet. The producer closes the logs channel after sending all messages. This signals the tee to stop and close the destinations.
The consumers run in separate goroutines and use a WaitGroup to signal completion.
func main() {
// ... setup from previous block ...
var wg sync.WaitGroup
wg.Add(2)
// Consumer for stdout.
go func() {
defer wg.Done()
for msg := range stdout {
fmt.Println("STDOUT:", msg)
}
}()
// Consumer for file sink.
go func() {
defer wg.Done()
for msg := range file {
fmt.Println("FILE:", msg)
}
}()
// Wait for all consumers to finish.
wg.Wait()
}
The WaitGroup ensures the main function waits for both consumers to drain their channels and exit. Without the WaitGroup, the main function might exit before the consumers finish, especially if the channels are buffered and the consumers are slow. The defer wg.Done() calls guarantee that the counter decrements even if a consumer panics, though panics in consumers are rare in this pattern.
Tee is a fan-out. One in, many out. Keep the destinations buffered.
Pitfalls and runtime errors
The biggest risk is deadlock. If you pass unbuffered channels to tee and a consumer is slow, the tee goroutine blocks on d <- v. Since the tee blocks, it can't read from src. If the producer is sending to src and src is unbuffered, the producer blocks. You get a deadlock. The compiler won't catch this. The runtime panics with fatal error: all goroutines are asleep - deadlock!. Always buffer destination channels or ensure consumers are fast enough to keep up with the producer.
Another pitfall is closing channels incorrectly. If you close a destination channel manually while the tee is still running, the tee will panic with panic: send on closed channel. The tee owns the lifecycle of the destination channels. Only the tee should close them. If you need to stop the tee early, close the source channel. The tee will detect the closure and clean up the destinations.
Goroutine leaks happen when the source channel never closes. The tee runs forever, waiting for values that never come. This is a common source of memory leaks in long-running services. Always ensure the source has a finite lifecycle or a cancellation path. In production code, your tee function should likely accept a context.Context as the first parameter. This allows the caller to cancel the tee if the source stream hangs or if the application is shutting down. Functions that take a context should respect cancellation and deadlines. Without context, a blocked tee can leak goroutines.
Deadlock hides in unbuffered channels. Buffer the destinations or measure the consumer speed.
When to use the tee pattern
Use the tee channel pattern when one producer needs to feed multiple independent consumers with the same data stream. Use a single channel with multiple receivers when consumers can share the work and each value only needs to be processed once. Use a broadcast channel with a mutex when you need to send a value to many goroutines and require synchronization on every send. Use a fan-out with separate channels when consumers have different processing speeds and you want to isolate backpressure. Use plain function calls when you don't need concurrency and can process values sequentially.
Tee duplicates work. Share the channel if you can split the load.