A common community question is why a Go service with many per-user NATS subscriptions can use much more memory with conn.SubscribeSync than expected, even when all subscriptions share one NATS connection.
In the Go NATS client, SubscribeSync is the blocking subscription API. It delivers messages through a buffered Go channel for each subscription, so messages can wait there until the application calls NextMsg to drain them.
That channel can be large by default. A 65,536-slot channel of message pointers is roughly 512 KiB of raw slot storage per subscription (8 bytes per pointer on a 64-bit build) before other Go runtime and subscription overhead. At 10,000 subscriptions, that component alone is on the order of several GiB.
If you have a large number of subscriptions in one process, prefer async Subscribe callbacks where possible, or explicitly reduce the sync channel length if the sync API is required.
NATS servers do track subscription interest, so subscription count matters on the server side too. However, the high per-subscription memory pattern described here is usually observed in the Go application process when it creates many SubscribeSync subscriptions.
When investigating, measure these separately:
Go heap profiles, runtime.MemStats, and process RSS can tell different stories, so use them together.
SubscribeSync costs moreA synchronous subscription needs somewhere to hold messages between arrival from the NATS connection reader and consumption by application code. In the Go client, that means a per-subscription buffered channel.
Conceptually:
1sub, err := nc.SubscribeSync(subject)2if err != nil {3 return err4}5
6msg, err := sub.NextMsg(time.Second)This is convenient, but at high subscription counts the per-subscription channel buffer becomes important. The memory cost is proportional to:
1number of subscriptions × channel capacity × per-slot storageThe exact memory usage depends on the Go client version, Go runtime, architecture, and how you measure it. But the main takeaway is stable: many sync subscriptions can allocate substantial client-side memory even before any meaningful application payloads are buffered.
The async API uses a callback instead of a blocking NextMsg call:
1subject := `user.` + userID2
3sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {4 // Route or process the message for this user.5})6if err != nil {7 return err8}9
10_ = subBecause the callback path does not allocate that large fixed channel buffer, it is usually a better fit for thousands of mostly independent subscriptions. In community testing, switching from SubscribeSync to async Subscribe reduced per-subscription memory from hundreds of KiB to the low KiB range, although the exact number should be validated in your own service.
Async subscriptions are not free: the client still tracks each subscription, callbacks must keep up with message delivery, and your application must handle concurrency carefully. But for many high-fan-out client processes, async subscriptions are the first option to try.
If your design depends on SubscribeSync, consider lowering the sync channel length. The nats.SyncQueueLen connect option sets this length for SubscribeSync subscriptions:
1nc, err := nats.Connect(2 nats.DefaultURL,3 nats.SyncQueueLen(4096),4)5if err != nil {6 return err7}Reducing the channel from 65,536 slots to 4,096 slots reduces the raw slot allocation by 16x. For 10,000 subscriptions, that changes the channel-slot component from roughly 5 GiB to roughly 320 MiB before other overhead.
This is a tradeoff, not a universal fix:
Confirm the current default channel length and slow-consumer behavior for the Go client version you are using.
A subject-per-user pattern can be reasonable, for example:
1user.<uuid>But if one process subscribes to thousands of these subjects, subscription count becomes part of your memory budget.
One alternative is to subscribe with a wildcard and demultiplex inside the application:
1user.*This reduces subscription count, but it changes the work the process performs. A wildcard subscription receives all matching messages for that subscription. If every application instance subscribes to the same wildcard, every instance may receive messages for users it does not own, which can waste CPU and network bandwidth.
Wildcard subscription can be a good tradeoff when:
It can be a poor tradeoff when message rates are high and wildcard matching causes significant over-delivery to each process.
A simple subscription-count test against Redis Pub/Sub may show much lower memory use in the client process. That does not necessarily mean the systems are doing equivalent work.
With NATS SubscribeSync, the Go client is explicitly allocating per-subscription pending queues. Other Pub/Sub implementations and client APIs may buffer differently, rely more on socket or output buffers, or apply different backpressure and slow-consumer behavior.
For an apples-to-apples comparison, include:
For a Go service with many per-user NATS subscriptions:
SubscribeSync, test the same workload with async Subscribe.High memory use with many Go NATS subscriptions is often not about sharing one NATS connection; it is about the subscription API and buffering model. SubscribeSync is convenient but can allocate a large queue per subscription. For large subscription counts, async Subscribe is usually the better starting point. If you need synchronous consumption, tune the channel size deliberately and test it against your real burst and latency requirements.
Want help from the NATS experts? Meet with our architects to get help tailored to your use case and environment.



News and content from across the community