How to Implement Pub/Sub with Channels in Go

Implement Go Pub/Sub by creating a channel with make(), sending data via goroutines, and receiving it with the <- operator.

The broadcast problem

You are building a service that tracks user activity. Every time a user completes a purchase, three different parts of your system need to react. The inventory service must deduct stock. The analytics pipeline needs to log the event. The email worker should queue a receipt. If you call each handler sequentially, a slow analytics database will delay the inventory update. If you spawn a goroutine for each handler inside the purchase function, you quickly lose track of which goroutines are still running and how to shut them down gracefully.

You need a decoupled way to distribute events. Publishers should drop a message and move on. Subscribers should pull messages at their own pace. The system should scale to any number of readers without the publisher knowing who they are.

Channels as the message bus

Go solves this with channels. A channel is a typed conduit that lets one goroutine send a value to another. The type chan T means the channel carries values of type T. Channels synchronize by default. An unbuffered channel blocks the sender until a receiver is ready, and blocks the receiver until a sender arrives. This blocking behavior is a feature. It prevents race conditions without explicit locks.

Think of a channel like a relay race baton pass. The runner holding the baton cannot move forward until the next runner reaches out and takes it. Both runners must be at the exchange zone at the same time. The baton transfers, and both runners continue. In Go, the baton is your data. The exchange zone is the channel. The runners are goroutines.

When you need pub/sub, you treat the channel as a broadcast pipe. One goroutine writes to it. Multiple goroutines read from it. The Go runtime handles the distribution automatically. Channels are synchronization primitives first. Messaging is just what happens during the synchronization.

Minimal pub/sub

Here is the simplest possible publisher and subscriber. The publisher runs in a background goroutine. The main goroutine acts as the subscriber.

package main

import "fmt"

func main() {
    // Unbuffered channel blocks until both sides are ready
    events := make(chan string)

    // Publisher runs independently and sends one message
    go func() {
        events <- "user_signed_up"
    }()

    // Subscriber blocks here until the publisher sends
    msg := <-events
    fmt.Println("Received:", msg)
}

The publisher sends a string. The subscriber receives it. The program prints the message and exits. Nothing fancy, but it demonstrates the core mechanic. The channel coordinates the two goroutines without shared memory or mutexes.

Channels are synchronization primitives first. Messaging is just what happens during the synchronization.

How the runtime schedules it

When the program starts, the main goroutine creates the channel and immediately spawns the publisher. The publisher tries to send to events. Because the channel is unbuffered, the runtime parks the publisher goroutine. It cannot proceed until someone reads from the channel.

The main goroutine then reaches the receive expression <-events. The runtime sees a parked sender and an active receiver. It pairs them up. The string moves from the publisher's stack to the receiver's variable. Both goroutines unblock. The main goroutine prints the result and finishes. The publisher goroutine also finishes because its function body is complete.

If you remove the go keyword, the program deadlocks. The main goroutine tries to send, then immediately tries to receive on the same channel. There is no second goroutine to satisfy the other side of the operation. The runtime detects the deadlock and panics with fatal error: all goroutines are asleep - deadlock!. This panic saves you from silent hangs.

The runtime scheduler is work-stealing. It moves goroutines between operating system threads to keep CPU cores busy. Channels are the primary way goroutines communicate across those threads safely. The channel data structure lives on the heap. It contains a circular buffer, a lock, and pointers to parked sender and receiver goroutines. When a send or receive happens, the runtime acquires the lock, copies the data, unblocks the waiting goroutine, and releases the lock. This design keeps contention low while guaranteeing memory safety.

Trust the scheduler. Write linear code. Let channels handle the coordination.

Realistic fan-out with cancellation

Real systems need multiple subscribers. They also need a way to shut down cleanly. Here is a fan-out pattern that distributes messages to several workers and respects a cancellation signal.

package main

import (
    "context"
    "fmt"
    "sync"
)

// Subscriber reads from a channel until context cancels
func Subscriber(ctx context.Context, id int, events <-chan string, wg *sync.WaitGroup) {
    defer wg.Done()
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d shutting down\n", id)
            return
        case msg, ok := <-events:
            if !ok {
                fmt.Printf("worker %d: channel closed\n", id)
                return
            }
            fmt.Printf("worker %d got: %s\n", id, msg)
        }
    }
}

The Subscriber function uses a select statement to listen on two paths. It checks the context for cancellation. It also checks the channel for new messages. The ok idiom detects when the channel closes. When the sender closes a channel, receivers get zero values and ok becomes false. This prevents infinite loops after the publisher finishes.

Context always goes as the first parameter in Go. The community convention names it ctx. Functions that accept a context should check ctx.Done() in long-running loops or before expensive operations. This pattern makes cancellation consistent across your codebase.

Here is the publisher and worker orchestration:

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    events := make(chan string, 10) // buffered to prevent publisher blocking
    var wg sync.WaitGroup

    // Spawn three independent subscribers
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go Subscriber(ctx, i, events, &wg)
    }

    // Publisher sends a batch of events
    for i := 0; i < 5; i++ {
        events <- fmt.Sprintf("event_%d", i)
    }

    // Close signals no more messages will arrive
    close(events)
    wg.Wait()
}

The channel is buffered to ten. This gives the publisher room to send several messages without blocking. It decouples the publisher's pace from the subscribers' pace. The sync.WaitGroup tracks active workers. The main function waits for all workers to finish before exiting.

Close channels from the sender side only. Never close a channel from a receiver. Closing a channel from multiple goroutines panics. The sender knows when the stream ends. The receiver just reads until the stream stops.

Where things go wrong

Pub/sub with channels looks simple until you add scale or error paths. The most common failure is a goroutine leak. A subscriber goroutine blocks on <-events forever because the publisher never closes the channel and never sends again. The goroutine stays parked in memory. Over time, thousands of parked goroutines exhaust the process heap. Always provide a cancellation path. Use context.WithTimeout or context.WithCancel so workers can break out of blocking receives.

Another trap is assuming channels distribute messages fairly. Unbuffered channels deliver messages to waiting receivers in an unspecified order. The runtime picks an available receiver. If one subscriber is slower, it might starve. Buffered channels change the behavior. The sender drops messages into the buffer. Receivers pull from the front. Fast subscribers drain the buffer quickly. Slow subscribers fall behind. This is usually fine for event streams, but it means channels are not a strict queue with guaranteed ordering across multiple readers.

Type mismatches cause compile-time rejections. If you try to send an int to a chan string, the compiler rejects the program with cannot use 1 (untyped int constant) as string value in send. Go's type system catches these mistakes before they reach production. You cannot accidentally route a user event into a payment channel.

Unused channels trigger a different error. If you declare a channel and never read from it or write to it, the compiler complains with declared and not used. This forces you to wire up both ends of the communication path.

Channels are not a substitute for a message broker. They live in process memory. If your publisher crashes, buffered messages vanish. If you need persistence, cross-process routing, or exactly-once delivery, you need Kafka, RabbitMQ, or Redis Streams. Channels are for in-process coordination.

The worst goroutine bug is the one that never logs. Always track active workers and respect cancellation.

When to reach for channels versus alternatives

Use an unbuffered channel when you need strict synchronization between a single sender and a single receiver. Use a buffered channel when the publisher must not block and temporary decoupling is acceptable. Use a fan-out pattern with multiple goroutines reading from one channel when you need to distribute events to independent workers. Use a dedicated message broker when you need persistence, cross-process routing, or guaranteed delivery across restarts. Use direct function calls when the operation is fast and synchronous: the simplest thing that works is usually the right thing.

Channels coordinate goroutines. They do not replace architecture.

Where to go next