NEW live workshops: Leaving TIBCO or Solace for NATS Adding an enterprise backbone above MQTT Active/active multi-cloud architectures
All posts

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.

Short answer

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.

First: confirm which process is using memory

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:

  • The NATS server process
  • The Go application process using the NATS client
  • Goroutines and stacks, if the application starts one goroutine per subscription
  • Pending message counts and slow-consumer errors

Go heap profiles, runtime.MemStats, and process RSS can tell different stories, so use them together.

Why SubscribeSync costs more

A 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:

1
sub, err := nc.SubscribeSync(subject)
2
if err != nil {
3
return err
4
}
5
6
msg, 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:

1
number of subscriptions × channel capacity × per-slot storage

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

Prefer async subscriptions for high fan-out

The async API uses a callback instead of a blocking NextMsg call:

1
subject := `user.` + userID
2
3
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
4
// Route or process the message for this user.
5
})
6
if err != nil {
7
return err
8
}
9
10
_ = sub

Because 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 you must use sync subscriptions, reduce the channel length

If your design depends on SubscribeSync, consider lowering the sync channel length. The nats.SyncQueueLen connect option sets this length for SubscribeSync subscriptions:

1
nc, err := nats.Connect(
2
nats.DefaultURL,
3
nats.SyncQueueLen(4096),
4
)
5
if err != nil {
6
return err
7
}

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:

  • Smaller channels use less memory.
  • Smaller channels tolerate smaller bursts.
  • Slow-consumer conditions may happen sooner if application code does not drain messages fast enough.
  • The right value depends on message rate, burst size, processing latency, and failure behavior.

Confirm the current default channel length and slow-consumer behavior for the Go client version you are using.

Consider whether you need one subscription per user

A subject-per-user pattern can be reasonable, for example:

1
user.<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:

1
user.*

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:

  • The process really should observe all matching messages.
  • Subjects include a partition, shard, tenant, or server identifier that lets each process subscribe to only its own subset.
  • The extra application-side routing is cheaper than maintaining many individual subscriptions.

It can be a poor tradeoff when message rates are high and wildcard matching causes significant over-delivery to each process.

Be careful comparing with Redis Pub/Sub

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:

  • Where buffering occurs: application heap, client library, server, socket buffers, or elsewhere
  • What happens when a consumer is slow
  • Whether messages are dropped, buffered, or backpressured
  • Whether wildcard or pattern subscriptions are part of the design
  • How many messages are actually delivered to each process, not just how many subscriptions are registered

Practical checklist

For a Go service with many per-user NATS subscriptions:

  1. Measure whether memory is growing in the NATS server or in your Go client process.
  2. If using SubscribeSync, test the same workload with async Subscribe.
  3. If sync is required, reduce the sync channel length and validate burst handling.
  4. Avoid one goroutine per subscription unless you have budgeted for goroutine stack overhead too.
  5. Revisit subject design to see whether partitioned wildcards can reduce subscription count without excessive over-delivery.
  6. Watch for slow-consumer errors and pending-message growth during load tests.

Conclusion

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.

Get the NATS Newsletter

News and content from across the community


Cancel