Most NATS incidents come from a mismatch between how operators think NATS works and how it actually works. Teams coming from Kafka or RabbitMQ carry a queue-centric mental model: messages go somewhere, wait there, and can be retrieved later. Core NATS does not work like that. The mismatch shows up at 3 a.m. as silent message loss, slow consumer cascades, or a Raft election storm nobody saw coming.

This article builds the mental model you need before touching any NATS runbook: what the server is doing internally, where it competes for resources, and which behaviors are by design rather than symptoms.

What NATS is and why the model matters

NATS is a subject-based message router written in Go. That sentence carries more operational weight than it appears to. It is not a broker that stores messages. It is not a queue that holds work. It receives messages on subjects, matches them against registered subscriptions, and writes them to the interested connections, in real time.

The consequence that bites teams hardest: in core NATS, if no subscriber is connected when a message is published, the message is silently discarded. No error, no log, no metric. This is fire-and-forget by design. If your architecture assumes at-least-once delivery, that guarantee has to come from JetStream, not from core NATS.

This one fact re-frames most “NATS is losing messages” reports: the server is usually doing exactly what it was designed to do.

How the routing engine works

The core of the server is an in-memory subject tree, a trie that maps subject strings to sets of subscriptions. Subjects are dot-delimited tokens (for example orders.us.east.created), and the trie supports two wildcards: * matches a single token, > matches one or more trailing tokens. Every inbound message is matched against this trie and fanned out directly to all matching subscribers. There is no intermediate queue between publisher and subscriber.

flowchart LR
    P[Publisher] -->|publish on subject| T[Subject trie]
    T -->|match and fan out| S1[Subscriber A write buffer]
    T -->|match and fan out| S2[Subscriber B write buffer]
    T -->|route interest exists| R[Route to peer server]
    T -->|no match| X[Message dropped silently]
    R --> T2[Peer subject trie]
    T2 --> S3[Subscriber C write buffer]

Two properties of this design matter operationally:

  • Fan-out is amplification. One inbound message becomes N outbound writes. A high out_msgs / in_msgs ratio is normal for fan-out workloads, but it also means any latency in the delivery path is multiplied across subscribers.
  • The trie is shared state. Subscriptions consume memory in the trie and in per-connection tracking. A subscription leak in one client application inflates the routing table, and in a cluster the leak propagates across routes to every server.

Per-connection machinery: where slow consumers come from

Every TCP connection to the server, whether an application client, a cluster route, a gateway, or a leaf node, gets dedicated read and write goroutines plus a per-connection pending write buffer. The write side is governed by write_deadline, which controls how long the server waits for a connection to drain before giving up.

When a connection cannot consume as fast as the server produces, the pending buffer grows. When it exceeds the threshold, the server marks the connection as a slow consumer and, by default, disconnects it. Two things about this mechanism are routinely misunderstood:

  1. A slow consumer event is the server correctly enforcing backpressure. The fault is on the slow connection: a subscriber doing synchronous I/O in its message handler, a GC pause, a congested network path. Investigating the server first wastes time.
  2. It applies to every connection type, not just clients. A slow consumer on a route or gateway means inter-server message delivery is backing up, with cluster-wide blast radius.

The leading indicator is per-connection pending bytes, visible via /connz (clients) and /routez (routes, as pending_size). Pending bytes grow before the server declares distress, so watching the precursor lets you act before the disconnect-reconnect spiral begins. That spiral is the classic slow consumer cascade: subscriber falls behind, server disconnects it, client auto-reconnects and resubscribes, the backlog hits immediately, and the cycle repeats as connection churn.

Clustering: routes, gateways, and leaf nodes

NATS scales out through three distinct connection types, and each one is, internally, just another client with its own goroutines and buffers:

MechanismTopologyInterest behaviorFailure mode to know
RoutesFull mesh between servers in one cluster (N servers means N-1 routes per server)Subscription interest propagates across the cluster so messages route only where subscribers existRoute slow consumers; route drops mean partition; reconnects trigger full interest re-propagation
GatewaysConnections between independent clusters (supercluster)Optimistic send at startup, then converges to interest-only mode per accountSlow consumers; a reconnected gateway can cause a temporary bandwidth spike; zero traffic on an idle gateway can be normal
Leaf nodesLightweight connection from an edge server to a hub clusterOne logical connection carrying multiplexed traffic for potentially many accountsA single leaf disconnect can cut many logical channels at once; the edge loses messaging until reconnect

Clustering does not change the core semantics. Messages still route in real time, there is still no intermediate queue, and every hop has its own buffer that can back up. A route count below N-1 in a cluster means a partition, and in a 2-node cluster losing the single route is a complete partition.

JetStream: what gets bolted on

JetStream is a persistence layer on top of core NATS, not a replacement for it. Enabling it adds subsystems that compete for the same host resources:

  • WAL-based storage. Each stream uses file or memory storage. File storage is a write-ahead log with block-level organization, plus an in-memory index mapping message sequence numbers to file block positions. JetStream disk I/O is sequential writes, historical reads, compaction, and snapshots.
  • Consumer state tracking. Delivery cursors, acknowledgment state, and redelivery timers, per consumer. With explicit acks, unacknowledged messages accumulate up to MaxAckPending; at that limit, delivery stalls silently. The stream looks healthy while the consumer is effectively down.
  • Raft consensus, at two levels. In clustered JetStream there is a meta Raft group for cluster-wide metadata, and each replicated stream (and its consumers) has its own Raft group. A per-stream group can fail independently while the meta group is healthy.
  • Background work. Compaction, retention enforcement, snapshot/restore, and catch-up replication for lagging peers.

Operationally, JetStream converts fire-and-forget into a system where storage, consumer lag, and Raft stability are the primary failure surfaces. Retention interacts with consumer health in ways that surprise people: with interest retention, a message is deleted only after all consumers acknowledge it, so one stalled consumer blocks retention for the whole stream and storage fills. Conversely, a stream with interest retention and zero consumers deletes every message on arrival: effectively /dev/null while looking perfectly healthy.

Where resource pressure shows up

NATS competes for a small set of host resources, each with a characteristic degradation shape:

ResourceWhat consumes itDegradation shape
File descriptorsOne per client connection, plus routes, gateways, leaf nodes, JetStream file handles, and listenersCliff-edge. At the OS ulimit -n, accept() fails and no new connections are possible. A default ulimit of 1024 is catastrophically low
MemorySubject trie, per-connection buffers (tens of KB each, more with pending data), JetStream caches, Raft stateSawtooth from Go GC is normal; monotonic growth without GC recovery is not. Ends in an OOM kill
CPUSubject matching per message, TLS handshakes, JetStream indexing and compaction, RaftTLS dominates CPU on workloads that are otherwise cheap per message; GC pauses show as spikes and can trigger Raft election timeouts
Disk I/OJetStream only: WAL writes, reads, compactionLatency-sensitive. Slow Raft log appends cause heartbeat delays and elections. Network-attached storage with variable latency is the top cause of Raft election storms
NetworkFan-out amplification, route traffic, gateway replicationGradual. TCP backpressure becomes delivery latency becomes slow consumer events

The interplay worth internalizing: slow consumers buffer in memory, memory pressure lengthens GC pauses, GC pauses delay Raft heartbeats, and delayed heartbeats cause elections that pause writes. A JetStream outage can have its root cause three steps away from where the alert fired.

The failure archetypes to recognize

Recognizing the shape early is most of the battle:

  • Slow consumer cascade. Disconnect-reconnect churn on clients, routes, or gateways. Check the slow consumer breakdown by connection type first; the remediation for a slow route is completely different from a slow client.
  • File descriptor exhaustion. Sudden inability to accept new connections while existing ones work fine. Check ulimit -n before anything else; it is a cliff-edge with no graceful degradation.
  • JetStream Raft instability. Network latency, disk I/O stalls, or GC pauses cause election timeouts. Leader flapping means write failures and stalled consumers, and simultaneous elections across many stream groups can make the cluster effectively unavailable.
  • Memory pressure. Slow consumer buffering, subscription trie growth, JetStream cache growth. Alert on the trend, not spikes.
  • Silent message loss in core NATS. Publishers send to subjects with zero subscribers and messages vanish. The only signal is out_msgs running far below in_msgs after adjusting for expected fan-out.

Signals to watch in production

These signals map directly onto the machinery above. The monitoring port (default 8222) must be explicitly enabled with -m 8222 or http_port: 8222; if it is not, none of these endpoints exist.

SignalWhy it mattersWarning sign
/varz slow_consumersThe single most important core NATS signal; break it down by connection type to size the blast radiusAny positive rate of change; slow consumers on routes or gateways are urgent
/connz?sort=pending pending_bytes and /routez pending_sizeLeading indicator before slow consumer disconnectionsSustained growth on any connection; any sustained non-zero pending on routes
/varz in_msgs vs out_msgsFan-out ratio and the only detection for zero-subscriber lossout_msgs flat or far below in_msgs against expected fan-out
/varz connections vs max_connections, plus OS fd countConnection saturation is cliff-edgeAbove roughly 85% of max_connections, or total FDs near ulimit
/varz routes vs cluster size minus onePartition detectionAny route missing for more than a minute; route flapping
/varz mem trendBuffering, trie growth, JetStream cache pressureMonotonic growth over hours without GC recovery
/jsz api.errors and api.inflightJetStream write-path healthSustained error rate; high inflight points at Raft or disk I/O
/jsz?consumers=true num_pending and num_ack_pendingWhether consumers actually keep up; ack_pending at MaxAckPending means stalled deliverySustained growth in either
/jsz meta_cluster leader and replica current/offline/lagMeta Raft stabilityFrequent leader changes; any offline or non-current peer

One instrumentation caution: /subsz is expensive on servers with many subscriptions and can degrade the server itself. Track the subscription count from /varz, not the list from /subsz.

How Netdata helps

The mental model above tells you what to correlate; the value of monitoring is seeing those correlations on one timeline instead of assembling them from curl during an incident:

  • Slow consumer events against connection churn and pending bytes, so you can tell a one-off client hiccup from an active cascade, and see backpressure building before disconnects start.
  • in_msgs against out_msgs over time, which makes zero-subscriber message loss visible as a fan-out asymmetry instead of an application-side mystery.
  • Route count and route health alongside throughput, so a cluster partition shows up immediately rather than as unexplained delivery gaps.
  • Process RSS and CPU next to connection and subscription counts, which distinguishes expected growth from buffer accumulation or a subscription leak.
  • JetStream aggregates (storage, API errors) against OS-level disk latency and iowait, the correlation that separates storage exhaustion from a disk I/O stall, two problems with very different fixes.