DEV Community

Cover image for Stop Spawning Infinite Goroutines: Use Tee-Channels in Go for Scalable Event Handling
Archit Agarwal
Archit Agarwal

Posted on โ€ข Originally published at Medium

Stop Spawning Infinite Goroutines: Use Tee-Channels in Go for Scalable Event Handling

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Image of Quadratic

Free AI chart generator

Upload data, describe your vision, and get Python-powered, AI-generated charts instantly.

Try Quadratic free

Top comments (0)

๐Ÿ‘‹ Kindness is contagious

Explore a trove of insights in this engaging article, celebrated within our welcoming DEV Community. Developers from every background are invited to join and enhance our shared wisdom.

A genuine "thank you" can truly uplift someoneโ€™s day. Feel free to express your gratitude in the comments below!

On DEV, our collective exchange of knowledge lightens the road ahead and strengthens our community bonds. Found something valuable here? A small thank you to the author can make a big difference.

Okay