NATS fan-out isn’t about sending a single message to many destinations; it’s about having many independent consumers each receive a copy of the same message.
Let’s see it in action. Imagine we have a service that publishes events whenever a new user signs up. We want to notify a few different downstream systems: a notification service, a data warehousing service, and a real-time analytics dashboard.
Here’s a simplified publisher in Go:
package main
import (
"log"
"time"
"github.com/nats-io/nats.go"
)
func main() {
// Connect to NATS
nc, err := nats.Connect(nats.DefaultURL)
if err != nil {
log.Fatalf("Failed to connect to NATS: %v", err)
}
defer nc.Close()
log.Println("Connected to NATS. Publishing user signup events...")
// Simulate user signups
for i := 1; i <= 5; i++ {
userID := i
message := []byte("user_signed_up:" + string(rune(userID)))
subject := "events.user.signup"
// Publish the message
if err := nc.Publish(subject, message); err != nil {
log.Printf("Error publishing message: %v", err)
} else {
log.Printf("Published: '%s' on subject '%s'", message, subject)
}
time.Sleep(2 * time.Second) // Wait a bit between events
}
// Give NATS time to deliver messages
nc.Flush()
log.Println("Finished publishing. Exiting.")
}
And here are three independent consumers, each subscribing to the same subject:
Consumer 1: Notification Service
package main
import (
"log"
"time"
"github.com/nats-io/nats.go"
)
func main() {
nc, err := nats.Connect(nats.DefaultURL)
if err != nil {
log.Fatalf("Failed to connect to NATS: %v", err)
}
defer nc.Close()
subject := "events.user.signup"
// Subscribe to the subject
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
log.Printf("Notification Service received message: '%s' on subject '%s'", string(msg.Data), msg.Subject)
// Simulate sending a notification
time.Sleep(500 * time.Millisecond)
})
if err != nil {
log.Fatalf("Failed to subscribe to subject '%s': %v", subject, err)
}
defer sub.Unsubscribe()
log.Printf("Notification Service subscribed to '%s'. Waiting for messages...", subject)
// Keep the subscription alive
runtime.Goexit()
}
Consumer 2: Data Warehousing Service
package main
import (
"log"
"time"
"github.com/nats-io/nats.go"
)
func main() {
nc, err := nats.Connect(nats.DefaultURL)
if err != nil {
log.Fatalf("Failed to connect to NATS: %v", err)
}
defer nc.Close()
subject := "events.user.signup"
// Subscribe to the subject
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
log.Printf("Data Warehousing Service received message: '%s' on subject '%s'", string(msg.Data), msg.Subject)
// Simulate writing to data warehouse
time.Sleep(1 * time.Second)
})
if err != nil {
log.Fatalf("Failed to subscribe to subject '%s': %v", subject, err)
}
defer sub.Unsubscribe()
log.Printf("Data Warehousing Service subscribed to '%s'. Waiting for messages...", subject)
// Keep the subscription alive
runtime.Goexit()
}
Consumer 3: Real-time Analytics Dashboard
package main
import (
"log"
"time"
"github.com/nats-io/nats.go"
)
func main() {
nc, err := nats.Connect(nats.DefaultURL)
if err != nil {
log.Fatalf("Failed to connect to NATS: %v", err)
}
defer nc.Close()
subject := "events.user.signup"
// Subscribe to the subject
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
log.Printf("Analytics Dashboard received message: '%s' on subject '%s'", string(msg.Data), msg.Subject)
// Simulate updating dashboard
time.Sleep(200 * time.Millisecond)
})
if err != nil {
log.Fatalf("Failed to subscribe to subject '%s': %v", subject, err)
}
defer sub.Unsubscribe()
log.Printf("Analytics Dashboard subscribed to '%s'. Waiting for messages...", subject)
// Keep the subscription alive
runtime.Goexit()
}
When the publisher sends a message to events.user.signup, NATS delivers a copy of that message to each of these three subscribers. Each subscriber processes the message independently. This is the core of fan-out: one publish, multiple independent deliveries.
The magic happens because NATS treats each Subscribe call as a distinct client request. When a message arrives on a subject, NATS checks its internal routing table to see which clients have active subscriptions to that subject. For every active subscription, it then forwards a copy of the message. There’s no single "group" receiving the message; it’s a series of individual deliveries orchestrated by the NATS server based on active subscriptions.
The problem this solves is decoupling producers from consumers. The publisher doesn’t need to know who is listening or how many are listening. It just broadcasts its events. Downstream services can subscribe or unsubscribe dynamically without affecting the publisher or other consumers. This makes systems highly flexible and scalable. If you need to add a fourth service to react to user signups, you just deploy it with a new nc.Subscribe("events.user.signup", ...) call.
The primary lever you control is the subject name. By using specific, hierarchical subject names (e.g., events.user.signup, metrics.cpu.usage, orders.created.international), you can route messages precisely. Consumers subscribe to the subjects they care about. NATS’s wildcard matching (* for a single token, > for multiple tokens) allows for flexible subscription patterns, but for a simple fan-out to known consumers, using the exact subject is standard.
The one thing most people don’t realize is how NATS handles message ordering and delivery guarantees in this scenario. For standard Subscribe calls (not queue subscribers), each message is delivered to every subscriber. If a subscriber is slow or temporarily disconnected, it will miss messages published while it was unavailable. NATS doesn’t buffer messages for standard subscribers indefinitely. You’re relying on the consumer to be available and process messages at a rate that keeps up with the publisher. If you need guaranteed delivery or at-least-once processing for individual consumers, you’d typically use NATS JetStream, which provides persistence and acknowledgments.
The next step is understanding how to manage message delivery guarantees for individual consumers when using fan-out.