How Channels Make Async Events Natural in Go
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 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.
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:
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:
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:
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:
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
- No callback hell — flat code, easy to read
- Built-in synchronization — channels handle race conditions
- Backpressure — buffered channels naturally handle load spikes
- Composability — fan-out, fan-in, merge, filter — all trivial
- Testability — send events to a channel in tests, no mocking needed
Key Takeaways
- Channels replace event buses — no framework needed for basic pub/sub
- Goroutines are your handlers — each runs independently
- Buffered channels handle backpressure — size them based on expected load
selectstatement — multiplex multiple channels in one goroutine- Close channels to signal completion — consumers use
rangeto drain
Go channels turn async events from a framework problem into a language feature. Once you think in channels, callback-based systems feel archaic.