JetStream Anti-Patterns: Avoid these pitfalls to scale more efficiently

jonzu2 pts0 comments

JetStream Anti-Patterns: Avoid these pitfalls to scale more efficiently | Synadia

NEW: Free hands-on NATS workshops. Live sessions on NATS fundamentals, leaf nodes, AI agents on NATS, & more.

All posts

Series: Design Patterns for Scaling NATS JetStream Anti-Patterns: Avoid these pitfalls to scale more efficiently<br>Andrew Connolly

• Jun 6, 2026<br>Open in ChatGPTChatGPTOpen in ClaudeClaudeOpen in CursorCursorOpen in PerplexityPerplexityShare on XXShare on LinkedInLinkedInShare via emailCopy as MarkdownCopy link

NATS was designed to be lightweight, efficient, and scalable. But distributed systems have inherent complexities - including the humans designing and building them - that can’t be solved entirely by code. So when it comes to scaling NATS, architecture and application design choices are critical to stability.

In this series, we’ll review some of the scaling anti-patterns that most commonly cause NATS server instability or slow-down. In this first post, we’ll focus on pitfalls to avoid when scaling JetStream consumers.

Another distributed systems truism: it’s more straightforward to walk through what not to do than to give a specific configuration or pattern that will always work. At scale, each use case has its quirks and there is no single ideal configuration.

If you’d like a custom review of your NATS architecture, from the experts who contributed to this piece, get in touch here.

Using Consumers at Scale

JetStream consumers are a powerful and flexible aspect of NATS. They allow clients to consume state, whether it comes from a stream, key-value bucket, or object store. Consumers can be ephemeral and are simple to create and remove quickly. Fleet management use cases love consumers for these reasons. But, at very high rates of consumer usage, certain operations are expensive and can cause locking in NATS servers.

Anti-pattern: Overusing consumer info

The consumer info call is one of those potentially expensive operations. Why? Let’s review what it actually does.

It fetches a list of metadata about a specific consumer, including configuration and state information:

$ nats consumer info ORDERS DISPATCH

Information for Consumer ORDERS > DISPATCH

Configuration:

Durable Name: DISPATCH

Pull Mode: true

Subject: ORDERS.processed

Deliver All: true

10

Deliver Last: false

11

Ack Policy: explicit

12

Ack Wait: 30s

13

Replay Policy: instant

14

Sampling Rate: 100

15

16

State:

17

18

Last Delivered Message: Consumer sequence: 1 Stream sequence: 1

19

Acknowledgment floor: Consumer sequence: 0 Stream sequence: 0

20

Pending Messages: 0

21

Redelivered Messages: 0

DISPATCHConfiguration: Durable Name: DISPATCH Pull Mode: true Subject: ORDERS.processed Deliver All: true Deliver Last: false Ack Policy: explicit Ack Wait: 30s Replay Policy: instant Sampling Rate: 100State: Last Delivered Message: Consumer sequence: 1 Stream sequence: 1 Acknowledgment floor: Consumer sequence: 0 Stream sequence: 0 Pending Messages: 0 Redelivered Messages: 0">

In a vacuum, it’s not inherently expensive. But consumer info was intended to provide insights for monitoring or debugging.

When the consumer info call is repeatedly used at scale, it creates expensive overhead. Consumer info has to go to the meta-leader before it returns a “does not exist” error and, if the consumer does exist, requires calculating state.

If you only want to know whether a consumer exists, those expensive state calculations were wasted. And at scale, that waste can add up.

A pattern like this:

consumer info call

consumer doesn’t exist > consumer create

consumer does exist > consumer update

which seems innocuous in a prototype or proof of concept, becomes very expensive across ten of thousands of clients.

Using consumer info to check if a consumer exists is unnecessary. Instead, just call consumer create

Using consumer create

When creating a consumer, if the consumer already exists with the same name on the specified stream, JetStream verifies the request against the existing consumer’s config. If they match, the call is idempotent and JetStream responds with a success message without making any changes.

If the config differs, JetStream will update the existing consumer (unless the operation tries to update a non-editable configuration, e.g. start sequence, in which case an error is returned). When the consumer doesn’t exist, a new one is created with the specified config .

Check Pending JetStream Messages

Consumer info is also frequently misused as a method for clients to check for pending messages. Instead, get this metadata from the last message fetched to avoid the unnecessary overhead of consumer info. What’s available in message metadata?

type MsgMetadata struct {

// Sequence is the sequence information for the message.

Sequence SequencePair

// NumDelivered is the number of times this message was delivered to the

// consumer.

NumDelivered uint64

// NumPending is the number of messages...

consumer sequence nats info jetstream scale

Related Articles