NATS — Stress
Use this to measure NATS JetStream throughput and compare it against the InMemory baseline and RabbitMQ results. The numbers reveal JetStream's persistence cost versus in-process, and the protocol efficiency difference versus AMQP — useful data before choosing a backend or sizing a deployment.
Prerequisites
- Docker (a NATS JetStream testcontainer is started automatically)
- Cargo feature:
nats
Run
cargo run --example nats_stress --features natsNarrow to one tier or handler profile:
cargo run --example nats_stress --features nats -- --tier moderate --handler fastRelease mode for representative numbers:
cargo run -q --release --example nats_stress --features natsExpected output
Non-deterministic. Look for these characteristic markers:
shove stress benchmarks — nats
scenarios: 60
[1/60] moderate | 20000msg | 1c | fast (1-5ms) ...
-> 12500.3 msg/s | dispatch p50=0.4ms p99=2.1ms | e2e p50=2.8ms p99=5.3ms | cpu=55% rss=38.2MB | 1.6s
...
Backend: nats
TIER MSGS C HANDLER MSG/SEC ...
moderate 20000 1 fast 12500 ...
moderate 20000 4 fast 42000 ...
...NATS JetStream typically achieves higher throughput than RabbitMQ (lower protocol overhead) but lower than in-memory (no persistence or network round-trip).
Source
//! Stress benchmarks for the NATS JetStream backend.
//!
//! Spins up a NATS JetStream testcontainer for the lifetime of the process.
//! Requires a running Docker daemon.
//!
//! cargo run -q --example nats_stress --features nats
//! cargo run -q --example nats_stress --features nats -- --tier moderate
#[path = "../common/stress_test.rs"]
mod harness;
use async_nats::jetstream;
use shove::nats::{NatsConfig, NatsConsumerGroupConfig};
use shove::{Broker, Nats};
use testcontainers::ImageExt;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::nats::{Nats as NatsImage, NatsServerCmd};
use harness::{HarnessConfig, run_all_scenarios};
const STREAM_NAME: &str = "shove-stress-bench";
#[tokio::main]
async fn main() {
let cmd = NatsServerCmd::default().with_jetstream();
let container = NatsImage::default()
.with_cmd(&cmd)
.start()
.await
.expect("failed to start NATS container");
let port = container
.get_host_port_ipv4(4222)
.await
.expect("failed to read NATS port");
let url = format!("nats://localhost:{port}");
wait_until_ready(&url).await;
let purge_url = url.clone();
let purge: harness::PurgeFn = Box::new(move || {
let url = purge_url.clone();
Box::pin(async move {
// Drop the whole stream (and its durable consumer) so the next
// scenario creates both fresh with its own config. JetStream
// `create_consumer` upserts, but changing `max_ack_pending` on an
// existing consumer requires explicit update — cleanest to drop.
let Ok(client) = async_nats::connect(&url).await else {
return;
};
let js = jetstream::new(client);
let _ = js.delete_stream(STREAM_NAME).await;
})
});
let hcfg = HarnessConfig::<Nats>::new("nats").with_purge(purge);
run_all_scenarios(
hcfg,
|| {
let url = url.clone();
async move {
Broker::<Nats>::new(NatsConfig::new(&url))
.await
.expect("connect NATS")
}
},
|consumers, prefetch, concurrent| {
NatsConsumerGroupConfig::new(consumers..=consumers)
.with_prefetch_count(prefetch)
.with_concurrent_processing(concurrent)
},
)
.await;
drop(container);
}
/// Block until JetStream is accepting requests. Testcontainers exits
/// `.start()` once nats-server logs that it's listening, but JetStream
/// initialization can lag a few hundred ms behind. Issuing an account-info
/// call confirms the JS API is actually responding.
async fn wait_until_ready(url: &str) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
if let Ok(client) = async_nats::connect(url).await {
let js = jetstream::new(client);
if js.query_account().await.is_ok() {
return;
}
}
if std::time::Instant::now() >= deadline {
panic!("NATS JetStream did not become ready within 30s");
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}Walkthrough
Stream purge strategy
with_purge(purge) injects a closure that drops and recreates the entire JetStream stream between scenarios using js.delete_stream(STREAM_NAME). This is necessary because changing max_ack_pending on an existing durable consumer requires an explicit UpdateConsumer call, which the current harness does not perform — cleanest to drop the stream and let the next scenario's declare recreate it fresh. The scenario boot overhead is small compared to the processing time for even the moderate tier.
NatsConsumerGroupConfig with with_concurrent_processing
The make_cfg closure produces:
NatsConsumerGroupConfig::new(consumers..=consumers)
.with_prefetch_count(prefetch)
.with_concurrent_processing(concurrent)The prefetch_count maps to JetStream's max_ack_pending — how many messages the JetStream server will push to the consumer before requiring acknowledgements. When --concurrent is passed, with_concurrent_processing(true) enables parallel handler invocations within a single consumer, which is especially effective for slow and heavy profiles.
Interpreting the results
Key metrics to compare across backends:
disp p50/p99— the latency from publish timestamp to handler entry. For NATS this includes JetStream replication and push-delivery overhead. Typically 0.2–2 ms at moderate load, compared to 1–5 ms for RabbitMQ.scaling_efficiency— how closely throughput scales with consumer count. NATS JetStream push consumers scale well up to the stream's partition count; above that point the bottleneck shifts to the JetStream server.RSS(MB)— messages are held in the JetStream server's storage, so in-process memory stays lower than for in-memory backends at equivalent message counts.
run_all_scenarios — coordinated group path
NATS implements HasCoordinatedGroups, so run_all_scenarios is used (not run_supervisor_scenarios). Each scenario creates a fresh Broker\<Nats\>, which provisions a new JetStream connection and durable consumer. The purge closure ensures the stream is empty before each scenario's publish phase.
What to try next
- Compare
--tier moderate --handler zerothroughput here against the InMemory and RabbitMQ stress results to quantify NATS's overhead relative to both extremes. - Run
--concurrent --handler slow— the throughput improvement will be pronounced because NATS's push delivery already fills themax_ack_pendingwindow; concurrent processing consumes that window faster. - Try
--tier extremein release mode — NATS typically sustains hundreds of thousands of msg/s forzerohandlers before JetStream becomes the bottleneck. - See the NATS backend overview for JetStream stream configuration options not covered by this harness.