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...