TechBlog

NATS JetStream vs Kafka — lightweight messaging for microservices and AI agents

Marcin Zygmunt · June 21, 2026 · 9 min read

Lightweight messaging for microservices and AI agents — a Kafka alternative, with a working Java demo.

NATS JetStream is a lightweight persistence layer on top of the NATS broker — a Kafka alternative that, for many use cases, is simpler and ships with features Kafka doesn’t give you out of the box. I built a demo in Java 25 + Micronaut: the same code publishes and consumes through both systems, measures latency, and shows JetStream’s features live. The code is public — docker compose up and it runs.

Below: the JetStream model vs Kafka’s log, latency measurements, five runnable scenarios (deduplication, replay, work queue, KV…), and use cases where NATS fits ideally — from edge and microservices to task queues and AI-agent communication.

Kafka in two sentences (you know it anyway)

Kafka is a distributed log — an append-only record split into partitions, read by consumer groups that track their own offset. It shines where you need huge throughput, durability, and a rich ecosystem (Connect, Streams, ksqlDB). Since version 4 it runs natively in KRaft mode.

The price of that power is operational complexity and a model where a lot is done around the broker: deduplication, task queues, replaying from a specific point. It’s doable — just not always elegant.

NATS and JetStream — a light core with strong features

NATS is a lightweight pub/sub broker: a single binary, sub-second startup, hierarchical subjects (orders.eu.*). On its own it delivers at-most-once — fast, but without durability.

JetStream is a persistence layer built into the same server and the same client. It adds streams, durable consumers, and guarantees — without standing up separate infrastructure. And this is where it gets interesting, because JetStream has a few things that take gymnastics in Kafka:

  • Per-message deduplication — set the Nats-Msg-Id header and the server drops repeats within a dedup window. Idempotent publish “for free”, durable across client sessions. In Kafka, producer idempotence is limited to a single session.
  • Client-side replay — the consumer picks its own start point: DeliverPolicy ByStartTime (from a moment) or ByStartSequence (from a number). Many independent consumers replay the same history without groups or resetting offsets.
  • Per-message acknowledgment — ack() (at-least-once), ackSync() (double-ack, confirmed by the server), nak(), redelivery after AckWait. Kafka commits a group’s offset, not an individual message.
  • WorkQueue — native queue semantics: a message goes to a single consumer and disappears after it’s acknowledged. In Kafka you emulate this with partitions and groups.
  • KV and Object store — key-value and object stores built on JetStream, in the same client. The Kafka client has no equivalent.

On top of that comes operational simplicity: one lightweight server (great for edge and leaf-node clusters too), a simple mental model.

Demo: one API, two backends

To keep the comparison fair, I built an app where the same code publishes and consumes through both systems. Stack:

  • Java 25, Micronaut 5, the micronaut-kafka and micronaut-nats modules
  • Kafka (KRaft) and NATS with JetStream in containers
  • HdrHistogram for latency percentiles

At its heart is a shared MessagePublisher port with two implementations (Kafka, JetStream) and a shared sink both consumers report to. That makes the feeder and the measurement backend-agnostic — you switch with DEMO_BACKEND=kafka|jetstream|both.

public interface MessagePublisher {
    Backend backend();
    void publish(String id, String payload, long sentAtEpochNanos);       // synchronous (demo)
    void publishAsync(String id, String payload, long sentAtEpochNanos);  // non-blocking (benchmark)
}

Kafka is handled declaratively (@KafkaClient / @KafkaListener), and JetStream via the raw jnats client with the newer, simpler API (Simplified Consumer) — deliberately, because the declarative, reactive client deferred publishing and skewed the measurement.

What you see live

./gradlew shadowJar
docker compose up --build

The feeder publishes a batch of messages to Kafka and JetStream, and the consumer (a separate container) prints what it received from both — with latency computed off a shared clock. A round-trip through both systems in a single terminal window.

Latency benchmark

The benchmark mode sends at a steady rate with a warm-up phase, asynchronously (so the sender keeps pace and doesn’t skew the result), measures latency against the scheduled send time (coordinated-omission-aware), and prints percentiles:

docker compose run --rm -e DEMO_MODE=benchmark -e DEMO_BACKEND=jetstream consumer
docker compose run --rm -e DEMO_MODE=benchmark -e DEMO_BACKEND=kafka     consumer

On a single node, with equal guarantees (Kafka acks=all + idempotence vs JetStream with a publish ack), at 500 msg/s, mean of 3 runs:

percentileKafkaJetStreamJS faster
p501.85 ms0.74 ms2.5×
p954.87 ms1.41 ms3.5×
p9910.59 ms2.94 ms3.6×
p99.919.38 ms6.61 ms2.9×
max21.48 ms8.49 ms2.5×

JetStream is faster at every percentile (~2.5–3.6×), and the tail is the most telling part: where Kafka blows out at p99.9/max (~19–21 ms), JetStream stays tight (~7–8 ms) — its worst case (max 8.5 ms) beats Kafka’s p99 (10.6 ms).

Two caveats, to be fair: this is one node (RF=1 — it doesn’t show the cost of replication), and the numbers are illustrative and heavily configuration-dependent. A fun finding from building the demo: when I published synchronously, Kafka’s latency “jumped” to tens of milliseconds — but that was sender backlog, not Kafka’s fault. After switching to async publishing, everything dropped back to sane values. A common lesson from benchmarks: first you measure your harness, only then the system.

The key point: JetStream’s real edge isn’t raw latency — it’s the features and the simplicity. And that’s what the next scenarios show.

JetStream feature scenarios

Each one is a self-contained, runnable example that prints an observable effect:

docker compose run --rm -e DEMO_MODE=dedup      -e DEMO_BACKEND=jetstream consumer
docker compose run --rm -e DEMO_MODE=guarantees -e DEMO_BACKEND=jetstream consumer
docker compose run --rm -e DEMO_MODE=replay     -e DEMO_BACKEND=jetstream consumer
docker compose run --rm -e DEMO_MODE=workqueue  -e DEMO_BACKEND=jetstream consumer
docker compose run --rm -e DEMO_MODE=kv         -e DEMO_BACKEND=jetstream consumer
  • dedup — two publishes with the same Nats-Msg-Id; the second comes back as duplicate=true, and the stream keeps one message.
  • guarantees — we receive a message and deliberately don’t ack it → after AckWait it arrives again (redelivery); after ackSync deliveries stop.
  • replay — we publish 5 events, then two independent consumers replay history from the 3rd event: one by sequence, the other by time — without resetting offsets.
  • workqueue — 6 jobs, two competing workers; each job handled exactly once, the queue drains after ack.
  • kv — put/get/update with revision numbers.

What it looks like in code

The best “for free” example — deduplication is a single messageId:

PublishOptions opts = PublishOptions.builder().messageId("order-42").build();
js.publish(subject, body, opts);
PublishAck ack = js.publish(subject, body, opts);   // same Nats-Msg-Id
// ack.isDuplicate() == true — the stream keeps a single message

And replay is the consumer choosing its start point — no offset resets:

ConsumerConfiguration.builder().deliverPolicy(ByStartTime).startTime(cutoff).build();
// or: .deliverPolicy(ByStartSequence).startSequence(seq)

The rest (guarantees, WorkQueue, KV) is in the repo — the code is deliberately readable so you can paste it straight into your own project.

Where JetStream really shines

AI agent systems — the killer use case

Building a multi-agent system? This is JetStream’s strongest use case today. Three pillars: fast messaging between agents (lots of small control messages — routing, tool calls, status updates), the WorkQueue pattern (a pool of agents pulls tasks from a queue, each handled exactly once), and dedup by ID — an agent can safely retry a command, and Nats-Msg-Id guarantees the tool won’t run the same task twice. All of it under one roof:

  • pub/sub over subjects (agent.planner.*, agent.tools.search) — natural routing of tasks and events between agents,
  • deduplication — an agent can safely retry a command; Nats-Msg-Id guarantees the tool runs it once (idempotent commands),
  • per-message ack + redelivery — a task handed to a worker agent won’t get lost; if it isn’t acked, it comes back,
  • KV store as shared memory/state — context, configuration, the agents’ “scratchpad”,
  • replay — a new (or restarted) agent replays the conversation/event history from a chosen point to catch up on context,
  • WorkQueue — a pool of worker agents pulls tasks from a queue, each handled once.

And all of this on a lightweight server that’s easy to run next to your app — without pulling in a heavy streaming platform just so agents can talk to each other.

Other good fits

  • Edge / IoT — a light broker, leaf nodes, working over flaky connectivity.
  • Task queues — native WorkQueue instead of emulating it with partitions.
  • Event sourcing / audit — replay by time or sequence as a built-in feature.
  • Microservices without heavy infrastructure — pub/sub, request-reply, and KV in one.
  • Live config and feature flags — a KV store with change watching.

Summary — what to pick

Reach for Kafka when you need extreme throughput, a rich streaming ecosystem (Connect/Streams), data-warehouse integrations, and you have a team that already knows it.

Consider NATS JetStream when you care about operational simplicity and need features in the core: deduplication, per-message ack, replay, queues, KV — or you’re building something lightweight: agent communication, edge, microservices. It often turns out you get “for free” what you’d have to bolt on in Kafka.

🔗 More: NATS · JetStream docs · Java client (jnats)

Best of all, just run the demo and see the difference for yourself 👇

🔗 Repo: github.com/marcinzygmunt-pl/jetstream-vs-kafka-demo