How to Use NATS for Messaging in Go

Web
Connect to a NATS server using the Go client library to publish and subscribe to messages on specific subjects.

How to Use NATS for Messaging in Go

You are building a service that processes images. The upload endpoint receives a file, but resizing and watermarking takes several seconds. You do not want the user to stare at a loading spinner while the CPU crunches pixels. Instead, you push the job to a queue and respond immediately with "Accepted". A background worker picks up the job, does the work, and saves the result. This decoupling is the core reason messaging systems exist. NATS is one of the fastest, simplest brokers for this pattern.

The postal service for microservices

NATS is a high-performance messaging system built for speed and simplicity. Think of it like a postal service for your microservices. Service A drops a letter in a mailbox labeled orders.created. Service B has a subscription to that mailbox. When the letter arrives, Service B reads it. The sender does not need to know who the receiver is, or if the receiver is even online. The broker handles the routing.

NATS supports two main patterns. Pub/Sub works like a radio broadcast. You publish to a subject, and every subscriber listening to that subject gets a copy. Request/Reply works like a phone call. You send a request and wait for a single response. The library handles the unique reply address automatically.

NATS uses a simple subject hierarchy separated by dots. A subject like orders.usa.created has three tokens. Subscriptions can use wildcards to match multiple subjects. A single asterisk * matches exactly one token. A greater-than > matches the rest of the subject.

Subscribe to orders.*.created to catch orders.usa.created and orders.europe.created. Subscribe to orders.> to catch everything under orders, including orders.usa.created and orders.usa.updated. This flexibility lets you route messages dynamically without changing code. You can restructure your subject hierarchy as your system grows.

Queue groups add load balancing. If multiple subscribers join the same queue group, the server delivers each message to only one subscriber. This is the standard way to scale workers. You can run ten instances of your worker service, all in the same queue group, and they will share the load automatically.

Core NATS is ephemeral. It does not store messages. If a subscriber is not connected when a message arrives, the message is dropped. This is by design. NATS prioritizes speed and simplicity. If you need durability, you need JetStream. JetStream adds persistence on top of the broker. It stores messages on disk and allows consumers to replay history. Use JetStream when message loss is unacceptable.

Keep your subject hierarchy flat. Deep nesting creates routing overhead and makes debugging harder.

Minimal example

Here is the simplest loop: connect, subscribe, publish, read. The subscriber blocks waiting for data, so the publish happens in a background goroutine to avoid a deadlock.

package main

import (
	"log"
	"time"

	"github.com/nats-io/nats.go"
)

func main() {
	// Connect to the default NATS server at localhost:4222.
	// In production, pass a specific URL or use environment variables.
	nc, err := nats.Connect(nats.DefaultURL)
	if err != nil {
		log.Fatal(err)
	}
	// Close the connection when main exits to free resources.
	defer nc.Close()

	// Subscribe to "hello" and wait for a message.
	// NextMsg blocks until data arrives or the timeout expires.
	sub, _ := nc.SubscribeSync("hello")
	msg, _ := sub.NextMsg(1 * time.Second)

	// Publish in a goroutine so the main thread can read the response.
	// This prevents a deadlock since NextMsg is blocking.
	go nc.Publish("hello", []byte("world"))

	log.Printf("Received: %s", msg.Data)
}

nats.Connect opens a TCP connection to the broker. NATS uses a binary protocol that is extremely lightweight. The handshake is fast, and the overhead per message is minimal. SubscribeSync registers interest in the subject "hello". The server remembers this mapping. Publish sends the bytes. The server looks up subscribers for "hello" and forwards the payload. NextMsg pulls the message from the client's local buffer.

The library handles reconnection automatically by default. If the network drops, the client tries to reconnect. You can configure the retry behavior or disable reconnection if you prefer explicit control.

Convention aside: if err != nil is verbose by design. The minimal example uses _ to keep the code short, but in production, you check every error. NATS operations can fail due to network issues, authentication, or subject permissions. The community accepts the boilerplate because it makes the unhappy path visible. Dropping errors with _ hides network failures. Use _ sparingly, and never with errors in production code.

Never ignore connection errors. A silent failure leaves your service talking to thin air.

Realistic example

Most applications use the Request/Reply pattern. A client sends a request and waits for a response. The server handles the request and replies. This decouples the caller from the implementation while keeping the interaction synchronous.

Here is the handler that processes requests. The callback receives the message and sends a response back to the requester.

// ProcessOrder handles incoming order messages.
// It reads the data and sends a response back to the requester.
func ProcessOrder(msg *nats.Msg) {
	// Construct a response payload based on the input.
	response := []byte("Processed: " + string(msg.Data))

	// Respond sends the reply to the unique Inbox address.
	// This address is set automatically by the Request call.
	err := msg.Respond(response)
	if err != nil {
		log.Printf("Respond failed: %v", err)
	}
}

The receiver name msg is standard for NATS callbacks. It matches the type *nats.Msg. Go convention prefers short receiver names that match the type, usually one or two letters. msg is clear and idiomatic.

Here is the client that sends the request. It uses a context to bound the wait time.

func main() {
	nc, err := nats.Connect(nats.DefaultURL)
	if err != nil {
		log.Fatal(err)
	}
	defer nc.Close()

	// Subscribe to handle incoming requests.
	// The callback runs in a goroutine per message.
	nc.Subscribe("orders.new", ProcessOrder)

	// Use context to bound the wait time for the reply.
	// This prevents the client from hanging if the server is slow.
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	// Request sends the payload and blocks until a reply arrives.
	// The server routes the request to the subscriber.
	reply, err := nc.RequestContext(ctx, "orders.new", []byte("order-123"))
	if err != nil {
		log.Fatal(err)
	}

	log.Printf("Reply: %s", reply.Data)
}

RequestContext takes a context as the first parameter. This is standard Go. Always pass context to operations that might block. It allows cancellation and deadlines. If the server does not reply within two seconds, the request fails with a timeout error. The context propagates through the call stack, ensuring resources are cleaned up.

Convention aside: context.Context always goes as the first parameter. Functions that take a context should respect cancellation and deadlines. The NATS client follows this convention. RequestContext checks the context before sending and while waiting. If the context is cancelled, the request aborts immediately.

Context is plumbing. Run it through every long-lived call site.

Pitfalls and errors

Goroutine leaks are the most common bug. When you use Subscribe with a callback, the library spawns a goroutine for each message. If your callback blocks forever, you consume resources. Always ensure callbacks return quickly. If you need to do heavy work, spawn a worker goroutine inside the callback, but be careful not to spawn unbounded goroutines. Use a worker pool or a channel to throttle processing. The worst goroutine bug is the one that never logs.

If the server is down, nats.Connect fails immediately. The compiler will not catch this, but the runtime returns an error. You get nats: no servers available if the URL is wrong. If you call Request without a subscriber, you hit a timeout. The error is nats: timeout. If you forget to check errors on Publish, you might think messages are sent when they are not. Publish can fail if the connection is broken. The error might be nats: connection closed.

Slow consumers are another issue. If you subscribe but do not read messages fast enough, the server might drop messages or close the connection. The error nats: slow consumer indicates the client is falling behind. Increase the buffer size or optimize the handler. You can configure the PendingLimits on the subscription to control the buffer size.

NATS does not validate message content. It treats messages as opaque bytes. If you send JSON, the subscriber must parse JSON. If you send binary data, the subscriber must handle binary data. Type safety is up to you. Use a serialization format like Protocol Buffers to keep payloads small and schema-safe.

Convention aside: gofmt is mandatory. Do not argue about indentation. Let the tool decide. Most editors run gofmt on save. NATS client code follows standard Go formatting. Trust gofmt. Argue logic, not formatting.

Measure your callback latency. A slow handler turns a fast broker into a bottleneck.

Decision matrix

Use NATS Pub/Sub when you need to broadcast events to multiple listeners without coupling the sender to receivers. Use NATS Request/Reply when a client needs a synchronous response from a service, similar to RPC but decoupled. Use NATS JetStream when you require message persistence, replay, or guaranteed delivery, as the core NATS broker is ephemeral. Use direct HTTP or gRPC when latency is critical and the caller needs immediate validation, as messaging adds network hops. Use a simple Go channel when the communication stays within a single process, as NATS introduces external dependencies.

NATS is fast, simple, and flexible. It handles routing, queuing, and request/reply patterns with minimal code. The trade-off is that core NATS does not store messages. Choose JetStream when durability matters. Pick the tool that matches your requirements.

Where to go next