NATS Core Concepts: Pub/Sub, Request/Reply, and Queues

A comprehensive introduction to the foundational messaging patterns in NATS, covering publish/subscribe, request/reply, and queue groups with practical TypeScript examples using the node-nats client library.

technical9 min readBy Klivvr Engineering
Share:

NATS is a connective technology built for the modern distributed system. At its heart, it is a simple, high-performance messaging system that moves data between applications, services, and devices. Unlike heavyweight message brokers that impose complex protocols and configuration, NATS operates on a principle of simplicity: subjects, messages, and subscriptions. Everything else builds on top of these primitives. In our node-nats client library, we expose these primitives through a type-safe TypeScript interface that makes it natural to build robust messaging into any application.

This article walks through the three core messaging patterns that NATS provides: publish/subscribe, request/reply, and queue groups. Each pattern addresses a different communication need, and understanding when to use each one is the foundation for building effective distributed systems.

Publish/Subscribe: The Foundation

Publish/subscribe is the simplest and most fundamental pattern in NATS. A publisher sends a message to a subject, and every active subscriber on that subject receives a copy of that message. There is no persistence, no acknowledgment, and no coupling between publisher and subscriber. If no subscriber is listening when a message is published, the message is simply discarded.

This fire-and-forget model is intentional. It provides the lowest possible latency and the highest possible throughput. For many use cases -- telemetry, logging, real-time notifications, and event broadcasting -- this is exactly the right trade-off.

Setting up a basic pub/sub flow with node-nats looks like this:

import { connect, StringCodec } from "nats";
 
async function main() {
  const nc = await connect({ servers: "nats://localhost:4222" });
  const sc = StringCodec();
 
  // Subscribe to a subject
  const sub = nc.subscribe("events.user.signup");
 
  (async () => {
    for await (const msg of sub) {
      const data = sc.decode(msg.data);
      console.log(`Received signup event: ${data}`);
    }
  })();
 
  // Publish a message
  nc.publish("events.user.signup", sc.encode(JSON.stringify({
    userId: "usr_abc123",
    email: "user@example.com",
    timestamp: Date.now(),
  })));
 
  await nc.drain();
}
 
main();

The StringCodec handles the encoding and decoding of message payloads as UTF-8 strings. For structured data, you will typically serialize to JSON and wrap it in the codec. The subscription is an async iterable, which integrates naturally with TypeScript's for await...of syntax.

Subjects in NATS use a dot-separated hierarchy. This is not just a naming convention; it enables powerful wildcard subscriptions. The * token matches a single segment, and > matches one or more trailing segments:

// Matches events.user.signup, events.user.login, etc.
const userEvents = nc.subscribe("events.user.*");
 
// Matches events.user.signup, events.order.created, events.payment.processed, etc.
const allEvents = nc.subscribe("events.>");
 
// Process messages from either subscription
(async () => {
  for await (const msg of userEvents) {
    console.log(`User event on ${msg.subject}: ${sc.decode(msg.data)}`);
  }
})();
 
(async () => {
  for await (const msg of allEvents) {
    console.log(`Any event on ${msg.subject}: ${sc.decode(msg.data)}`);
  }
})();

The subject hierarchy is one of NATS's most underappreciated features. By designing your subject namespace carefully, you can create flexible routing topologies without any broker configuration changes. A service that needs all payment events subscribes to payments.>. A service that only cares about refunds subscribes to payments.refund.*. The NATS server handles the routing with zero overhead.

Request/Reply: Synchronous Communication

While pub/sub is inherently asynchronous, many distributed systems also need synchronous request/reply communication. NATS provides this through a pattern built on top of pub/sub: the requester publishes a message with a unique reply subject, and the responder publishes its response to that reply subject.

The node-nats client abstracts this pattern into a clean API:

import { connect, StringCodec, JSONCodec } from "nats";
 
const jc = JSONCodec();
 
// Service that responds to requests
async function startUserService() {
  const nc = await connect({ servers: "nats://localhost:4222" });
 
  const sub = nc.subscribe("services.users.get");
 
  for await (const msg of sub) {
    const request = jc.decode(msg.data) as { userId: string };
 
    // Simulate a database lookup
    const user = {
      id: request.userId,
      name: "Jane Doe",
      email: "jane@example.com",
      createdAt: "2025-01-15T10:30:00Z",
    };
 
    // Reply to the requester
    msg.respond(jc.encode(user));
  }
}
 
// Client that makes requests
async function queryUser() {
  const nc = await connect({ servers: "nats://localhost:4222" });
 
  const response = await nc.request(
    "services.users.get",
    jc.encode({ userId: "usr_abc123" }),
    { timeout: 5000 }
  );
 
  const user = jc.decode(response.data);
  console.log("User:", user);
 
  await nc.drain();
}

The request method returns a promise that resolves with the first reply message. The timeout option is essential in production: without it, a missing or slow responder would cause the request to hang indefinitely. When the timeout expires, the promise rejects with a NatsError that you can handle appropriately.

Under the hood, NATS creates a unique inbox subject for each request (something like _INBOX.xK7z9q2f). The responder sees this reply subject in msg.reply and publishes its response there. This mechanism requires no special server configuration and imposes no additional overhead beyond a normal publish.

One powerful aspect of request/reply is that you can have multiple potential responders. If you need a "scatter/gather" pattern where you collect responses from several services, you can use the lower-level subscription API:

async function scatterGather(nc: NatsConnection) {
  const inbox = nc.subscribe(nc.options.inboxPrefix + ".*", {
    max: 3,
    timeout: 2000,
  });
 
  nc.publish("services.pricing.quote", jc.encode({
    product: "premium-plan",
    region: "eu-west",
  }));
 
  const responses: unknown[] = [];
  for await (const msg of inbox) {
    responses.push(jc.decode(msg.data));
  }
 
  return responses;
}

Queue Groups: Load Balancing

In a pub/sub system, every subscriber receives every message. This is ideal for broadcasting, but problematic when you want to scale a service horizontally. If you have five instances of an order processing service, you do not want each order processed five times.

Queue groups solve this elegantly. When multiple subscribers join the same queue group, NATS delivers each message to exactly one member of the group. The selection is random but distributed, effectively providing built-in load balancing with no external coordinator.

import { connect, JSONCodec } from "nats";
 
const jc = JSONCodec();
 
async function startWorker(workerId: number) {
  const nc = await connect({ servers: "nats://localhost:4222" });
 
  // The second argument is the queue group name
  const sub = nc.subscribe("tasks.process-order", {
    queue: "order-processors",
  });
 
  console.log(`Worker ${workerId} started, listening for orders...`);
 
  for await (const msg of sub) {
    const order = jc.decode(msg.data) as { orderId: string; amount: number };
    console.log(`Worker ${workerId} processing order ${order.orderId}`);
 
    // Simulate processing
    await processOrder(order);
 
    // If this is a request/reply, respond
    if (msg.reply) {
      msg.respond(jc.encode({ status: "processed", workerId }));
    }
  }
}
 
// Start multiple workers -- in production, these would be separate processes
Promise.all([
  startWorker(1),
  startWorker(2),
  startWorker(3),
]);

Queue groups interact naturally with request/reply. When a request is published to a subject with queue group subscribers, exactly one subscriber receives the request and sends the reply. This means you can scale your request/reply services horizontally simply by running more instances with the same queue group name.

The queue group name is arbitrary, but we recommend naming it after the service role: order-processors, payment-validators, notification-senders. This makes monitoring and debugging straightforward when you inspect active subscriptions on the NATS server.

A subtle but important detail: queue group subscribers and non-queue subscribers on the same subject coexist independently. If you have three queue group members and two regular subscribers on tasks.process-order, each message is delivered to one queue group member and to both regular subscribers. This lets you combine load-balanced processing with monitoring or auditing subscriptions on the same subject.

Combining Patterns: A Practical Example

Real applications rarely use a single messaging pattern in isolation. A typical microservice combines pub/sub for event broadcasting, request/reply for synchronous queries, and queue groups for scalable processing. Here is a more complete example that ties these patterns together in an order management context:

import { connect, JSONCodec, NatsConnection } from "nats";
 
const jc = JSONCodec();
 
interface Order {
  orderId: string;
  userId: string;
  items: Array<{ productId: string; quantity: number; price: number }>;
  total: number;
}
 
async function startOrderService(nc: NatsConnection) {
  // Queue group for handling order creation requests
  const createSub = nc.subscribe("services.orders.create", {
    queue: "order-service",
  });
 
  for await (const msg of createSub) {
    const request = jc.decode(msg.data) as Omit<Order, "orderId">;
 
    const order: Order = {
      ...request,
      orderId: `ord_${Date.now()}`,
    };
 
    // Store the order (simplified)
    await saveOrder(order);
 
    // Broadcast the event to all interested parties
    nc.publish(
      "events.orders.created",
      jc.encode(order)
    );
 
    // Reply to the requester
    if (msg.reply) {
      msg.respond(jc.encode({ orderId: order.orderId, status: "created" }));
    }
  }
}
 
async function startNotificationService(nc: NatsConnection) {
  // Regular subscription (not queue group) -- every instance sends notifications
  const sub = nc.subscribe("events.orders.created");
 
  for await (const msg of sub) {
    const order = jc.decode(msg.data) as Order;
 
    // Request user details from the user service
    const userResponse = await nc.request(
      "services.users.get",
      jc.encode({ userId: order.userId }),
      { timeout: 3000 }
    );
 
    const user = jc.decode(userResponse.data) as { email: string; name: string };
    await sendEmail(user.email, `Order ${order.orderId} confirmed`, order);
  }
}
 
async function startAnalyticsService(nc: NatsConnection) {
  // Subscribe to all events for analytics
  const sub = nc.subscribe("events.>", {
    queue: "analytics-workers",
  });
 
  for await (const msg of sub) {
    const event = jc.decode(msg.data);
    await recordAnalyticsEvent(msg.subject, event);
  }
}

This example demonstrates several important patterns. The order service uses a queue group for request/reply, ensuring that each creation request is handled by exactly one instance. After creating the order, it publishes an event using regular pub/sub, which multiple downstream services can consume independently. The notification service subscribes without a queue group because each instance might serve a different notification channel. The analytics service uses a queue group with a wildcard subscription, efficiently distributing the load of recording all events.

Practical Tips for Subject Design

Subject naming deserves deliberate thought because it determines your routing flexibility. Here are patterns we have found effective:

Use a consistent hierarchy: {domain}.{entity}.{action} for events (events.orders.created) and services.{service}.{operation} for request/reply (services.users.get). This separation makes it easy to apply different policies, permissions, and subscriptions to events versus service calls.

Keep subjects lowercase with dots as separators. Avoid encoding variable data like user IDs into subjects; use the message payload for that. A subject like events.orders.created is correct. A subject like events.orders.usr_123.created creates an explosion of unique subjects that complicates monitoring and makes wildcard subscriptions less useful.

Plan for versioning from the start. When a message schema changes in a breaking way, introduce a new subject (services.users.v2.get) rather than modifying the existing one. This allows consumers to migrate at their own pace.

Conclusion

The three core patterns in NATS -- publish/subscribe, request/reply, and queue groups -- provide a complete toolkit for inter-service communication. Pub/sub delivers low-latency event broadcasting. Request/reply enables synchronous service-to-service calls. Queue groups add horizontal scalability without external load balancers. The node-nats client library exposes these patterns through ergonomic, type-safe TypeScript APIs that integrate naturally with async/await. By understanding these primitives and combining them thoughtfully, you can build messaging architectures that are simple, performant, and resilient. The next articles in this series build on these foundations, adding persistence with JetStream and production hardening with connection management and security.

Related Articles

business

Operating NATS in Production: Monitoring and Scaling

A practical operations guide for running NATS in production environments, covering monitoring strategies, capacity planning, scaling patterns, upgrade procedures, and incident response for engineering and platform teams.

12 min read
business

Messaging Architecture for Fintech Systems

A strategic guide to designing messaging architectures for financial technology systems, covering regulatory requirements, data consistency patterns, auditability, and the role of NATS in building compliant, resilient fintech infrastructure.

11 min read
technical

Securing NATS: Authentication and Authorization

A comprehensive guide to securing NATS deployments with authentication mechanisms, fine-grained authorization, TLS encryption, and account-based multi-tenancy, with practical TypeScript client configuration examples.

10 min read