The Problem with Async in Most Languages

In most languages, async events mean callbacks, promises, or async/await chains. They work, but they get messy fast:

javascript
// JavaScript callback hell
fetchUser(id, (user) => {
fetchOrders(user.id, (orders) => {
  processPayment(orders[0], (result) => {
    sendEmail(result, (err) => {
      if (err) handleError(err);
    });
  });
});
});

Go takes a completely different approach. Instead of nesting callbacks, you use channels to pass events between goroutines. The result is flat, readable code that handles concurrency naturally.

Channels as Event Pipes

A channel in Go is a typed conduit. You send a value in one end, receive it on the other. This simple concept replaces entire event bus frameworks in other languages.

events.go
go
package main

import "fmt"

type Event struct {
  Type    string
  Payload interface{}
}

func main() {
  events := make(chan Event)

  // Producer
  go func() {
      events <- Event{Type: "user.created", Payload: "Jay"}
      events <- Event{Type: "order.placed", Payload: 42}
      close(events)
  }()

  // Consumer
  for event := range events {
      fmt.Printf("Handling %s: %v\n", event.Type, event.Payload)
  }
}

No callbacks. No promises. Just send and receive. The range loop over the channel handles the async flow naturally.

Fan-Out: Multiple Handlers for One Event

Real systems need multiple handlers per event. In Go, this is trivial with goroutines:

fan-out.go
go
func fanOut(events <-chan Event) {
  var wg sync.WaitGroup

  // Handler 1: Send email
  wg.Add(1)
  go func() {
      defer wg.Done()
      for e := range events {
          if e.Type == "user.created" {
              sendEmail(e.Payload.(string))
          }
      }
  }()

  // Handler 2: Update analytics
  wg.Add(1)
  go func() {
      defer wg.Done()
      for e := range events {
          trackEvent(e.Type, e.Payload)
      }
  }()

  wg.Wait()
}

Each handler runs independently. No shared state. No race conditions. The channel handles synchronization.

Fan-In: Merging Multiple Event Sources

Sometimes you need to combine events from multiple sources into one stream:

fan-in.go
go
func merge(channels ...<-chan Event) <-chan Event {
  var wg sync.WaitGroup
  merged := make(chan Event)

  for _, ch := range channels {
      wg.Add(1)
      go func(c <-chan Event) {
          defer wg.Done()
          for e := range c {
              merged <- e
          }
      }(ch)
  }

  go func() {
      wg.Wait()
      close(merged)
  }()

  return merged
}

Now events from HTTP handlers, gRPC services, and cron jobs all flow into one channel. Your event processor doesn’t care where they came from.

Real-World Pattern: Event Bus

Here’s a reusable event bus I’ve used in production services:

eventbus.go
go
type EventBus struct {
  subscribers map[string][]chan Event
  mu          sync.RWMutex
}

func New() *EventBus {
  return &EventBus{subscribers: make(map[string][]chan Event)}
}

func (b *EventBus) Subscribe(eventType string, bufferSize int) <-chan Event {
  ch := make(chan Event, bufferSize)
  b.mu.Lock()
  b.subscribers[eventType] = append(b.subscribers[eventType], ch)
  b.mu.Unlock()
  return ch
}

func (b *EventBus) Publish(event Event) {
  b.mu.RLock()
  defer b.mu.RUnlock()

  for _, ch := range b.subscribers[event.Type] {
      select {
      case ch <- event:
      default:
          // Drop if buffer full (backpressure)
      }
  }
}

Usage:

main.go
go
bus := New()

// Subscribe
orders := bus.Subscribe("order.placed", 100)
notifications := bus.Subscribe("user.created", 50)

// Publish from anywhere
bus.Publish(Event{Type: "order.placed", Payload: order})

// Process
go func() {
  for e := range orders {
      processOrder(e.Payload.(Order))
  }
}()

Why This Matters

  1. No callback hell — flat code, easy to read
  2. Built-in synchronization — channels handle race conditions
  3. Backpressure — buffered channels naturally handle load spikes
  4. Composability — fan-out, fan-in, merge, filter — all trivial
  5. Testability — send events to a channel in tests, no mocking needed

Key Takeaways

  1. Channels replace event buses — no framework needed for basic pub/sub
  2. Goroutines are your handlers — each runs independently
  3. Buffered channels handle backpressure — size them based on expected load
  4. select statement — multiplex multiple channels in one goroutine
  5. Close channels to signal completion — consumers use range to drain

Go channels turn async events from a framework problem into a language feature. Once you think in channels, callback-based systems feel archaic.