NEW: The Edge Autonomy Gap report. AI is arriving at the edge — 500 practitioners say the infrastructure isn't ready.
All posts

A common community question is how to process JetStream messages with multiple application instances while preventing races for stateful updates, such as applying several updates for the same user in the exact order they were published.

The short answer: if you need one-at-a-time processing for a JetStream consumer, set MaxAckPending to 1 and have your workers share that same consumer. This serializes delivery for that consumer, so only one message is in flight at a time, but it also makes that consumer a throughput bottleneck. Pair it with idempotent processing, because a message can still be redelivered. To scale while preserving order, partition the work so each key is serial, but different keys can run in parallel.

First, be precise about consumers and workers

In JetStream, a consumer is server-side state: it tracks delivery position, acknowledgements, redeliveries, and related configuration. Application instances that fetch or subscribe are workers or client subscribers.

That distinction matters:

  • Multiple independent durable consumers over the same stream each maintain their own position. They are not a competing worker group; each can receive its own copy of the matching messages.
  • Multiple workers sharing the same durable pull consumer, or a push consumer configured with a delivery (queue) group, can distribute work from one consumer’s state.

If the goal is to have ConsumerA and ConsumerB divide work without duplicating it, they should share the same JetStream consumer rather than each using a separate durable consumer for the same filter.

Use MaxAckPending=1 for strict serial processing

MaxAckPending limits how many messages may be outstanding, meaning delivered but not yet acknowledged, for a consumer.

When MaxAckPending is set to 1, JetStream will not allow that consumer to have a second unacknowledged message in flight. In practice, this means:

  1. A worker receives the next message.
  2. The worker performs the side effect, such as updating a database.
  3. The worker acknowledges the message.
  4. Only then can the next message be delivered for that consumer.

For a stream containing messages like:

1
msgs.received_from.alice { set_age: 50 years }
2
msgs.received_from.alice { set_age: 35 years }
3
msgs.received_from.alice { set_age: 70 years }

A shared consumer filtered to msgs.received_from.* with MaxAckPending=1 will process those matching messages one at a time. If one worker receives the first update, another worker cannot receive the second update from that same consumer until the first message has been acknowledged.

This is the simplest way to use acknowledgement as the ordering gate.

One caveat matters here: MaxAckPending=1 gates distinct messages, but it does not guarantee that a single message is processed only once. If a worker’s AckWait deadline elapses before it acknowledges — because processing is slow or the ack is lost — JetStream redelivers that same message. With multiple workers sharing the consumer, the redelivery can go to a different worker while the first is still running, so both process the same message at once.

To keep processing genuinely safe:

  • Set AckWait comfortably above your worst-case processing time.
  • For long-running work, send periodic in-progress (“working”) acknowledgements to extend the deadline rather than letting it expire.
  • Make the side effect idempotent or version-aware so a duplicate or redelivered message cannot corrupt state.

Put differently: MaxAckPending=1 serializes the stream of new messages, while a sane AckWait and idempotent side effects cover the redelivery edge case.

The tradeoff: the whole consumer becomes serial

The important downside is that MaxAckPending=1 applies to the consumer, not just to one subject token or one logical entity.

If the consumer filters msgs.received_from.*, then all matching messages are effectively processed one at a time:

1
msgs.received_from.alice
2
msgs.received_from.bob
3
msgs.received_from.carol

Even though Alice, Bob, and Carol might be independent, a single consumer with MaxAckPending=1 serializes all of them. Multiple workers can still provide resilience because another worker can continue after a failure or redelivery, but they do not increase concurrent processing for that consumer.

So yes: this pattern gives ordered processing, but it does not scale throughput for that consumer. It mainly gives you failover and operational resilience: if a worker stops, another picks up the redelivered message, and at any moment only one message from that consumer is being worked.

Do not confuse this with an ordered consumer helper

JetStream client libraries may expose an ordered consumer helper for ordered message delivery. That solves a different problem: it gives a single client a fast, gap-free, in-order replay of a stream. An ordered consumer is ephemeral, bound to one subscriber, and does not use acknowledgements; it recreates itself and resets position if it detects a gap. Because it has no acknowledgement step and no shared-worker model, it cannot gate side-effecting work spread across multiple workers.

For side-effecting work across multiple workers, the key control is still the acknowledgement boundary. If the requirement is that the next database update must not begin until the previous one is complete and acknowledged, use an explicit consumer configuration such as MaxAckPending=1 and design around its throughput implications.

Scaling pattern: partition by the ordering key

Most systems do not actually need one global serial lane. They need serial processing per key.

For example:

  • Updates for Alice must be processed in order.
  • Updates for Bob must be processed in order.
  • Alice and Bob can be processed at the same time.

That is a partitioning problem. The usual design is:

  1. Choose the ordering key, such as account ID, device ID, tenant ID, or user ID.
  2. Map that key to a partition, often with a stable hash.
  3. Ensure every message for the same key goes to the same partition.
  4. Process each partition with its own serial lane, commonly using MaxAckPending=1 per partition consumer.
  5. Run partitions in parallel.

For example, instead of one broad lane for all senders, you might publish or route messages into partitioned subjects such as:

1
msgs.received_from.p00.alice
2
msgs.received_from.p01.bob
3
msgs.received_from.p00.carol

Then create consumers for partition filters such as:

1
msgs.received_from.p00.*
2
msgs.received_from.p01.*

Each partition consumer can use MaxAckPending=1, preserving order within that partition while allowing different partitions to make progress independently.

You do not necessarily have to compute the partition token in every publisher. NATS server supports deterministic subject mapping, including a built-in partitioning function that maps a chosen subject token onto a fixed number of partitions. Configured as an account subject mapping, it routes messages to partitioned subjects consistently, so the partitioning logic lives in one place instead of in every producer.

This does not make a single hot key parallel. If Alice has a very high volume of updates and those updates must be strictly ordered, Alice remains limited by one serial lane. That is inherent in the requirement.

Alternative: make the database enforce correctness

Another way to reduce ordering pressure in the messaging layer is to make updates idempotent and versioned.

For state changes, consider including an application-level sequence, version, timestamp, or stream-derived ordering marker in the message, and have the database apply updates conditionally. For example, the database can reject an update if it is older than the version already stored.

This can be a better fit when:

  • Duplicate delivery must be safe.
  • A worker might complete a side effect but fail before acknowledging.
  • You care about final state more than executing every intermediate side effect.
  • The database is the source of truth for conflict detection.

This does not remove the need to think about ordering, but it moves part of the correctness guarantee to the state store, where compare-and-set or transaction semantics may already exist.

Alternative: use a lock or lease, carefully

If workers must coordinate dynamically around a key, a distributed lock or lease can be used. NATS Key/Value can provide a simple building block for this: a conditional create operation succeeds only when a key does not already exist, which can be used to claim a lock-like key.

That approach has costs and caveats:

  • It adds extra round trips and contention.
  • Leases need expiration behavior: a bucket-wide TTL expires every key after the same age, while newer NATS servers also support per-key TTLs, which fit lock keys better.
  • Workers must release only locks they own.
  • Expired leases can create subtle races unless the protected resource also uses fencing tokens or conditional writes.
  • It is usually slower and more complex than partitioning the workload up front.

Locks can be appropriate, but they are not a generic, efficient replacement for a good partitioning model.

Practical recommendation

Use the simplest design that matches the real ordering requirement:

RequirementSuggested approach
Every matching message must be processed globally one at a timeOne shared durable consumer with MaxAckPending=1
Messages must be ordered per entity, but entities are independentPartition by entity key; use one serial consumer per partition
Workers may race, but stale writes must not winAdd versions or sequence checks in the database
Dynamic coordination is unavoidableConsider a lease or lock, and account for failure modes

For many applications, the best answer is not global ordering. It is keyed ordering: preserve order where correctness requires it, and let unrelated keys run concurrently.

Summary

MaxAckPending=1 is the direct JetStream control for strict one-at-a-time processing on a consumer. It is useful and simple, but it serializes the whole consumer filter and therefore limits throughput.

To scale, design the subject and consumer model around the real ordering key. Keep each key, or each partition of keys, on a serial lane; run independent lanes in parallel; and make side effects idempotent or version-aware whenever possible.


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