Why NATS SubscribeSync Uses 512KB Per Subscription
A customer reported that NATS SubscribeSync() uses 0.5 MiB per subscription – roughly two orders of magnitude more than Redis SUBSCRIBE. For 10,000 user-specific subscriptions, that’s 5 GiB of memory just for subscription state.
The claim holds up empirically. Three approaches reduce memory by 94-99% with minimal code changes.
The Claim
The customer had a server subscribing to per-user NATS subjects using conn.SubscribeSync(). Each subscription consumed ~512 KiB. The equivalent Redis pattern used low single-digit KiB per subscription server-side.
The order-of-magnitude difference is real and architectural, not a bug.
Why 512 KiB
NATS SubscribeSync() pre-allocates a Go channel buffer per subscription whose size is controlled by the connection’s SubChanLen option (default DefaultMaxChanLen = 64 * 1024):1
// In SubscribeSync() (nats.go):
mch := make(chan *Msg, nc.Opts.SubChanLen)
// At the default of 65,536 slots x 8 bytes per pointer = 512 KiB per subscription
This buffer exists for a reason: it provides high-performance, non-blocking message delivery. If a subscriber is slow, messages queue in the buffer rather than blocking the publisher or being dropped immediately. Each subscription is independently buffered, which means slow processing on one subscription doesn’t affect others.
Redis takes the opposite approach: minimal per-subscription state, no client-side pre-allocation, immediate delivery to the socket. Slow subscribers are buffered at the server up to client-output-buffer-limit pubsub (default 8 MiB soft / 32 MiB hard), then forcibly disconnected once the limit is exceeded. The buffer exists but it lives on the server and is shared across clients rather than pre-allocated per subscription.
The trade-off: NATS spends memory to protect against message loss during processing delays. Redis spends nothing and accepts the consequences.
Measured Results
I tested three approaches with 1,000 subscriptions each:
| Approach | Memory/Sub | 10k Subs | Reduction |
|---|---|---|---|
Default SubscribeSync() |
512 KiB | ~5 GiB | baseline |
SyncQueueLen(4096) |
32 KiB | ~330 MiB | 94% |
Async Subscribe() |
~4-10 KiB | ~40-100 MiB | 98-99% |
The async figure covers the Subscription struct plus a per-subscription waitForMsgs goroutine; goroutine stacks start at 2 KiB and grow under load, so the realistic floor is higher than the struct size alone would suggest. The numbers assume all subscriptions share a single connection – each additional connection adds two goroutines, an 8 MiB reconnect buffer (DefaultReconnectBufSize), and parser state.
The Fixes
One-line fix: reduce the buffer
nc, _ := nats.Connect(nats.DefaultURL,
nats.SyncQueueLen(4096), // 4k slots instead of 64k
)
sub, _ := nc.SubscribeSync("subject")
// 32 KiB per subscription instead of 512 KiB
This trades buffer depth for memory. With 4,096 slots instead of 65,536, you can still absorb bursts of several thousand messages per subscription before back-pressure kicks in. For most workloads this is more than sufficient.
Better fix: use async subscriptions
nc, _ := nats.Connect(nats.DefaultURL)
nc.Subscribe("user."+userID, func(msg *nats.Msg) {
processMessage(msg)
})
// ~4-10 KiB per subscription (struct + goroutine stack)
Async subscriptions do not allocate a chan *Msg – delivery flows through the connection’s dispatch path – but each async subscription does spawn a waitForMsgs goroutine and carries a sync.Cond. The reduction is still dramatic (98-99% vs default SubscribeSync()), just not the bare struct size.
Best fix: rethink the subscription model
// Instead of 10,000 per-user subscriptions:
nc.Subscribe("user.*", func(msg *nats.Msg) {
userID := extractUserID(msg.Subject)
processMessage(userID, msg)
})
// Single subscription: a few KiB total
If all 10,000 user subjects share the same handler logic, a single wildcard subscription eliminates the problem entirely. Filter by subject in the handler.
When the default makes sense
The 64k buffer is not waste. It serves workloads where:
- Individual subscriptions receive high-volume bursts
- Processing latency varies and messages need to queue
- Slow consumers need protection from message loss
If your subscriptions are low-volume and high-cardinality (many subjects, few messages each), the default buffer is oversized. Tune it down or use async subscriptions.
A practical rule of thumb
If you have hundreds or thousands of subscriptions and each one receives only occasional traffic, default to async Subscribe(). The 512 KiB-per-sub SubscribeSync() buffer was designed for the opposite case: a small number of subscriptions, each handling bursty input, where dropping messages or blocking publishers is unacceptable. Reach for SubscribeSync() (and tune SyncQueueLen to match your expected burst depth) when you actually need that protection – otherwise the default is sized for a workload you do not have.
-
The
DefaultMaxChanLenconstant (64 * 1024 = 65,536) is defined in nats.go. TheSyncQueueLenoption setsOpts.SubChanLenon the connection at connect time; everySubscribeSync()on that connection uses the same value. There is no per-subscription override – to get different buffer sizes you need separate connections. See SyncQueueLen. ↩︎