Imagine you're working on an app that allows users to sign up for free. But you also want to make sure that someone from the sales team follows up to nudge the user to subscribe. So you have two tasks after the user is successfully signed up:
- Send a welcome email to the user.
- Notify the sales team on Slack.
Naturally, your first version looks something like this:
- User signs up
- You send a welcome email
- Once that's successful, you notify the sales team
Sounds clean.
But here's the thing - you've now tied the success of the sales notification to the welcome email, which is not just a coupling nightmare, it's also a recipe for "Oops, we never told the sales team."
⚠️ The Goroutine Trap: Scaling Gone Wild
So you think, "Let's parallelise it! Fire two goroutines!"
Great. Until…
You host a workshop, and suddenly, a hundred thousand users are signing up.
Now your server is firing two goroutines per user and context switching like it's in a dance battle. CPU usage spikes, latency increases, and your devops team starts slacking you 😅.
💡 Observing the Pattern: The Tasks are Independent!
Take a closer look. The welcome email and the Slack notification are independent. They don't need to wait on each other. They don't even need to happen in real-time.
Which begs the question - why not broadcast the event and let each downstream process consume it at its own pace?
Enter: Tee-Channels in Go.
🧵 What is a Tee-Channel in Go?
A tee-channel is like a broadcast system for Go channels. One input channel, multiple output channels. Each downstream consumer listens to their dedicated output and does their thing.
You don't spawn two goroutines per user anymore. You pipe the user once, and split it out - cleanly.
👨💻 Let's Build It: A Tee-Channel Implementation in Go
Here's a simple, scalable version that processes signups, and then concurrently (but safely) fans out to:
- Send welcome emails
- Notify the marketing team
Step 1: Define a simple DTO
type userDTO struct {
id int
name string
email string
}
Step 2: Create a helper to respect done context
We'll use orDone
so that everything shuts down gracefully if the context is cancelled.
func orDone(done <-chan struct{}, c <-chan userDTO) <-chan userDTO {
valStream := make(chan userDTO)
go func() {
defer close(valStream)
for {
select {
case <-done:
return
case v, ok := <-c:
if !ok {
return
}
select {
case valStream <- v:
case <-done:
}
}
}
}()
return valStream
}
Step 3: Implement the Tee-Channel logic
func tee(
done <-chan struct{},
in <-chan userDTO,
) (<-chan userDTO, <-chan userDTO) {
out1 := make(chan userDTO, 1_000)
out2 := make(chan userDTO, 1_000)
go func() {
defer close(out1)
defer close(out2)
for val := range orDone(done, in) {
var o1, o2 = out1, out2
for i := 0; i < 2; i++ {
select {
case <-done:
case o1 <- val:
o1 = nil
case o2 <- val:
o2 = nil
}
}
}
}()
return out1, out2
}
Here's what's happening:
- We listen to the input stream (
in
) - Each input value is sent to both output channels exactly once.
- It's concurrency-safe and honours context cancellation.
Step 4: Handlers for Email and Slack
func sendWelcomeEmail(done <-chan struct{}, ch <-chan userDTO) {
for u := range orDone(done, ch) {
fmt.Println("📧 Sending welcome email to", u.email)
}
fmt.Println("✅ Email handler done")
}
func sendNotification(done <-chan struct{}, ch <-chan userDTO, slackChannel string) {
for u := range orDone(done, ch) {
fmt.Println("📣 Notifying", slackChannel, "team for user", u.name)
}
fmt.Println("✅ Slack handler done")
}
Step 5: Putting It All Together
func singnup(u userDTO, ch chan<- userDTO) {
// Simulate saving to DB
ch <- u
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
notificationChannel := make(chan userDTO, 1_000)
// Split the input to two output handlers
ch1, ch2 := tee(ctx.Done(), notificationChannel)
// Start handlers
go sendNotification(ctx.Done(), ch1, "marketing")
go sendWelcomeEmail(ctx.Done(), ch2)
// Simulate signups
singnup(userDTO{id: 1, name: "Archit", email: "a@example.com"}, notificationChannel)
singnup(userDTO{id: 2, name: "Lilly", email: "lilly@example.com"}, notificationChannel)
time.Sleep(2 * time.Second) // Let handlers finish
}
You can find this complete code in my GitHub : https://github.com/architagr/The-Weekly-Golang-Journal/tree/main/tee-channel
🧠 A Few Pro Tips
- The
orDone
pattern ensures your goroutines stop listening when the context is cancelled. -
tee()
is the magic sauce - it splits the input stream cleanly. - This is future-proof. You can later plug more consumers like analytics, onboarding workflows, etc., without modifying the producer.
🎯 When Should You Use Tee-Channels?
✅ When multiple consumers need the same data
✅ When you want to decouple event producers from consumers
✅ When you want to avoid unbounded goroutine creation
Not a good fit if:
- You need real-time delivery with strict ordering guarantees
- You expect high fan-out (many consumers) - in that case, use a pub-sub system
🧵 Final Thoughts: Small Pattern, Big Impact
Honestly, this pattern looks simple. But in high-scale systems, it becomes a lifesaver. It gives you concurrency without chaos and scalability without the CPU meltdown.
So next time someone signs up on your app - just tee it up! 😄
Stay Connected!
💡 Follow me on LinkedIn: Archit Agarwal
🎥 Subscribe to my YouTube: The Exception Handler
📬 Sign up for my newsletter: The Weekly Golang Journal
✍️ Follow me on Medium: @architagr
👨💻 Join my subreddit: r/GolangJournal