DEV Community

Cover image for NATS & JetStream: Lightweight Messaging for Edge & Real-Time Pipelines
Gowtham Potureddi
Gowtham Potureddi

Posted on

NATS & JetStream: Lightweight Messaging for Edge & Real-Time Pipelines

nats jetstream is the pairing that lets you run a real messaging backbone in a 15–20 MB single binary — no JVM, no ZooKeeper, no KRaft quorum, no separate coordination service — and still get durable streams, at-least-once delivery, key/value state, and object storage when you need them. Core NATS is the fire-and-forget layer: a publisher sends a message on a dotted subject, every interested subscriber gets it, and if nobody is listening the message evaporates. JetStream is the persistence layer bolted on top: it captures subjects into an append-only stream on disk, and consumers replay that stream at their own pace with acknowledgements, redelivery, and deduplication.

That split is a genuinely different shape from the log-partition model most engineers meet first through Kafka. There is no partitioned topic to size, no consumer-group rebalance storm, and no broker cluster that needs a beefy box; a NATS server happily runs on a Raspberry Pi at a factory or in a moving vehicle and folds into a central cluster over a single outbound connection. This guide walks the four ideas an interviewer actually probes — the core subject/pub-sub/request-reply model, JetStream streams and consumers, at-least-once delivery with message dedup, and the edge topology that makes NATS "edge-native" — and contrasts each with Kafka on footprint and latency, pairing every section with a Solution-Tail interview answer: code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.

PipeCode blog header for NATS & JetStream — bold white headline 'NATS + JetStream' with subtitle 'Lightweight Messaging, Edge, Real-Time' and a stylised subject-to-stream scene on a dark gradient with purple, green, orange, and blue accents and a small pipecode.ai attribution.

When you want hands-on reps immediately after reading, drill the streaming practice library →, rehearse the consumer-and-ack logic on the event-processing practice set →, and pressure-test your real-time designs on the real-time-analytics practice set →.


On this page


1. Why NATS is lightweight messaging for edge and real-time

NATS is a single Go binary you drop anywhere — that one fact decides where it fits at the edge

The one-sentence invariant: a NATS server is one ~20 MB statically-linked Go binary with no external dependencies, so messaging becomes a process you run rather than a cluster you operate. Everything that makes NATS attractive for edge and real-time work follows from that. There is no JVM to tune, no ZooKeeper or KRaft quorum to babysit, no separate schema registry required to send a byte payload; nats-server starts in milliseconds, holds a low, flat memory footprint, and clusters by pointing servers at each other.

The two layers — core NATS and JetStream.

  • Core NATS is at-most-once. Publish/subscribe over subjects, in memory, fire-and-forget. If no subscriber is connected, the message is gone. This is the fast, stateless path — sub-millisecond fan-out for RPC, control planes, and live telemetry.
  • JetStream is at-least-once. A persistence subsystem built into the same binary (enable with -js) that captures subjects into durable streams and lets consumers replay them with acks and redelivery. This is the path for work queues, event history, and anything that must survive a restart.
  • You opt into durability per subject. Core and JetStream coexist on one connection; you pay the storage/latency cost only for the subjects you place under a stream.

Subject addressing instead of partitioned topics.

  • A subject is a dotted string like orders.eu.new, chosen at publish time — not a pre-declared, pre-partitioned topic. Subscribers pick subjects (with wildcards) at subscribe time. The broker routes purely by subject match.
  • No partition count to size up front. Where Kafka forces an early "how many partitions?" decision that bounds consumer parallelism, NATS scales fan-out by subject and parallelism by queue group, both dynamic.

Where NATS sits against the alternatives.

  • vs Kafka. Kafka is a partitioned, replicated commit log optimized for very high sustained throughput and long retention — excellent for analytics firehoses. NATS wins on footprint (MBs vs a JVM + KRaft), on end-to-end latency (sub-ms core), on request-reply as a first-class primitive, and on running natively at the edge. Kafka wins when you need million-msg/s per-partition ordered throughput and a mature stream-processing ecosystem.
  • vs RabbitMQ. RabbitMQ is a feature-rich AMQP broker with per-queue routing; NATS is lighter, simpler to operate, and adds native clustering and edge leaf nodes, at the cost of AMQP's richer per-message routing semantics.
  • vs MQTT brokers. MQTT is purpose-built for IoT device fan-in; NATS speaks MQTT too (JetStream-backed) while also giving you streams, KV, and request-reply in the same server.

What interviewers listen for.

  • Do you separate "core NATS = at-most-once, JetStream = at-least-once" in the first sentence? — senior signal.
  • Do you justify NATS by footprint and latency at the edge, not "it's newer than Kafka"? — required framing.
  • Do you reach for NATS when the answer is "I need messaging on a Raspberry Pi / in a vehicle / behind a firewall" rather than as a blanket Kafka replacement? — senior signal.
  • Do you mention subject-based routing and request-reply as things Kafka does not do natively? — the whole point.

Worked example — the same binary, core then JetStream

Detailed explanation. The canonical NATS "hello world" is a subject publish and a wildcard subscribe with nothing persisted — pure core. The moment you need the message to survive a subscriber being offline, you enable JetStream and add a stream over the same subject, changing nothing about how the publisher sends. That continuity is the point: the wire protocol and the subject stay identical; durability is a server-side capture you switch on.

Question. Show a core publish/subscribe on orders.new, then the one extra step that makes those same messages durable so a consumer that was offline can still read them.

Input.

step action subject persisted?
core publish + subscribe orders.new no
jetstream add stream capturing orders.> orders.new yes

Code.

nats sub  "orders.new"           ## core NATS: live subscriber
nats pub  "orders.new" '{"id":1}'  ## delivered only if a subscriber is up

## JetStream — durable capture of the same subject space
nats stream add ORDERS \
  --subjects "orders.>" \
  --storage file --retention limits --max-age 24h
nats pub  "orders.new" '{"id":2}'  # now stored in the ORDERS stream
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. The nats sub / nats pub pair is core: the router matches the subject orders.new and delivers to any live subscriber; with no subscriber, the message is simply dropped. nats stream add ORDERS --subjects "orders.>" tells the server to capture every message whose subject matches orders.> into a file-backed stream with 24-hour retention. From that point the identical nats pub orders.new both fans out live (core) and lands in the stream, so a consumer created tomorrow can still replay it.

Output.

scenario subscriber offline at publish message later readable?
core only yes no — dropped
JetStream stream yes yes — replay from stream

Rule of thumb. Start on core NATS for anything ephemeral (RPC, live metrics); the day a message must not be lost when a consumer is down, put its subject under a JetStream stream — the publisher code does not change.


2. Core NATS — subjects, wildcards, queue groups and request-reply

Subjects, wildcards, queue groups and request-reply are the entire core model — learn these four and the rest is JetStream

Core NATS has a tiny surface area, and an interviewer who asks "walk me through NATS routing" wants exactly four ideas in order: how subjects are named, how wildcards match them, how a queue group turns broadcast into load-balancing, and how request-reply works without any broker-side RPC support.

Subjects — the address space.

  • A subject is dot-delimited tokens, e.g. orders.eu.new has tokens orders, eu, new. Case-sensitive, no spaces. You choose the hierarchy; a good one reads left-to-right general → specific.
  • Publish and subscribe both name subjects. The publisher picks a concrete subject; the subscriber picks a subject that may contain wildcards. The server routes by matching the two.

Wildcards — * and >.

  • * matches exactly one token. orders.*.new matches orders.eu.new and orders.us.new, but not orders.new (missing a token) nor orders.eu.new.v2 (extra token).
  • > matches one or more trailing tokens (the tail). orders.> matches orders.new, orders.eu.new, and orders.eu.new.v2. It must be the last token.
  • Combine them. orders.*.> means "orders, any region, then anything after". Wildcards make one subscription cover a whole subtree — no per-partition wiring.

Queue groups — competing consumers.

  • A plain subscription broadcasts: every subscriber on orders.new gets every message.
  • A queue subscription load-balances: subscribers that join the same queue group on the same subject share the messages — the server delivers each message to exactly one member. Add more members to scale horizontally; a crash just removes a member.
  • This is how you scale workers without partitions: N stateless workers join queue group workers on jobs.> and the server round-robins across the healthy ones.

Request-reply — RPC over pub/sub.

  • The requester publishes with a reply subject. NATS auto-generates a unique, temporary reply subject (an _INBOX.<random>) and subscribes to it before sending.
  • The responder replies to that subject. It reads the request's reply field and publishes the answer there; the requester's inbox subscription receives it.
  • It is just pub/sub underneath, so request-reply inherits the same routing and queue-group scaling — you can put a pool of responders behind one service subject via a queue group.

Iconographic core-NATS diagram — a dot-delimited subject with star and greater-than wildcards, a publisher fanning to matching subscribers, a queue group load-balancing across workers, and a request-reply pair using an _INBOX reply subject.

Worked example — one subject, three subscribers, wildcards deciding who hears it

Detailed explanation. The clearest way to internalise wildcards is to fix one published subject and vary the subscriptions. A single publish to orders.eu.new is heard or ignored by each subscriber purely by whether its subject pattern matches — no configuration, no routing table you maintain.

Question. A publisher sends one message on orders.eu.new. Three subscribers exist on orders.eu.new, orders.*.new, and orders.>, plus one on orders.new. Which receive the message?

Input.

subscriber pattern intent
orders.eu.new exact
orders.*.new any region, "new" event
orders.> everything under orders
orders.new two-token subject

Code.

import asyncio
import nats

async def main():
    nc = await nats.connect("nats://localhost:4222")
    await nc.subscribe("orders.eu.new")   # exact
    await nc.subscribe("orders.*.new")    # single-token wildcard
    await nc.subscribe("orders.>")        # tail wildcard
    await nc.subscribe("orders.new")      # different arity
    await nc.publish("orders.eu.new", b'{"id": 1}')
    await nc.flush()
    await nc.drain()

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. The published subject is orders.eu.new (three tokens: orders, eu, new). orders.eu.new matches exactly. orders.*.new matches because * consumes the single middle token eu. orders.> matches because > consumes the tail eu.new. orders.new does not match — it only has two tokens and there is no wildcard, so the arity differs. The server evaluates each subscription independently and delivers one copy per matching subscription.

Output.

subscriber pattern receives message?
orders.eu.new yes
orders.*.new yes
orders.> yes
orders.new no

Rule of thumb. * is one token, > is the rest; if the token counts do not line up (and there is no trailing >), the subject does not match — arity is checked before content.

NATS interview question on the core routing model

Question. You have a jobs.> work stream and want to run a pool of stateless workers so that each job is processed by exactly one worker, scaling by simply starting more worker processes, with automatic failover if one dies. There is no JetStream yet — just core NATS. How do you wire the subscription, and what guarantees do you get?

Solution Using a queue-group subscription

Code.

import asyncio
import nats

async def worker(name: str):
    nc = await nats.connect("nats://localhost:4222")

    async def handle(msg):
        print(f"{name} processed {msg.subject}: {msg.data!r}")

    # Same queue name "workers" => server load-balances across all members
    await nc.subscribe("jobs.>", queue="workers", cb=handle)
    await asyncio.Event().wait()   # run forever

## Start this process N times (or N tasks); each joins queue group "workers"
asyncio.run(worker("w1"))
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

published subject queue-group members up delivered to
jobs.email w1, w2, w3 exactly one (e.g. w2)
jobs.report w1, w2, w3 exactly one (e.g. w1)
jobs.email w2 crashes → w1, w3 exactly one healthy member
  1. Every worker subscribes to the same subject jobs.> with the same queue name workers, which registers them as competing consumers.
  2. For each message, the NATS server picks exactly one healthy member of the queue group and delivers only to it — this is server-side load balancing, no partitions involved.
  3. Starting more worker processes adds members and increases throughput linearly; nothing needs re-sharding.
  4. If a member dies, the server simply stops choosing it; in-flight core messages it had not processed are lost (at-most-once) — for redelivery on crash you would move jobs.> under a JetStream work-queue stream.

Output:

property core NATS queue group
distribution each message to exactly one member
scaling add processes, no partition math
failover dead members skipped automatically
delivery guarantee at-most-once (no redelivery on crash)

Why this works — concept by concept:

  • Queue group — members sharing a queue name on a subject become competing consumers; the server delivers each message once across the group, giving load balancing without a partition count.
  • Subject wildcardjobs.> lets one subscription cover every job type, so adding a new jobs.sms subject needs no new wiring.
  • Elastic scaling — parallelism is the number of live members, changed at runtime, unlike Kafka where consumer parallelism is capped by partition count.
  • At-most-once caveat — pure core has no redelivery; if exactly-once-ish work matters, the same queue group over a JetStream stream adds acks and redelivery.
  • Cost — routing is O(1) per message against an in-memory subscription index; memory is O(subscriptions), independent of message volume.

Streaming
Topic — streaming
Pub/sub and message-routing problems

Practice →

Events Topic — event-processing Competing-consumer and fan-out problems

Practice →


3. JetStream streams and consumers

A stream stores the messages, a consumer is your read cursor — durable vs ephemeral and ack policy are the knobs that matter

JetStream splits persistence into two objects that interviewers constantly conflate, so keep them crisp: a stream is the stored, append-only sequence of messages captured from one or more subjects; a consumer is a stateful view over that stream that tracks how far a client has read and acknowledged. One stream can feed many independent consumers, each at its own position.

Streams — the durable log.

  • A stream captures subjects. --subjects "orders.>" means every message on a matching subject is stored, each getting a monotonically increasing stream sequence number.
  • Storage is file or memory. File is durable across restarts; memory is faster but volatile. Streams can be replicated (--replicas 3) across a cluster for fault tolerance.
  • Retention policy decides when messages age out. limits (default) keeps messages until a size/age/count limit; interest drops a message once all bound consumers have acked it; workqueue keeps each message until one consumer acks it, then deletes it — a true queue.

Consumers — the read view.

  • Durable consumers persist their position. Give a consumer a durable_name and its ack floor survives client restarts, so it resumes exactly where it left off. This is the default for production workers.
  • Ephemeral consumers are throwaway. No durable_name; the server garbage-collects them when the client disconnects. Good for one-off tails and dashboards.
  • Push vs pull. A push consumer streams messages to a subject the client subscribes to (server-paced, flow-controlled). A pull consumer lets the client fetch(batch, expires) on demand — the modern default for work queues because the client controls its own back-pressure.

Ack policies — the delivery contract.

  • AckExplicit. The client must ack each message; unacked messages are redelivered. Required for work queues; the safe default.
  • AckAll. Acking sequence N acks everything up to N — cheaper, for ordered streaming where you process in order.
  • AckNone. No acks; the server never redelivers — fastest, for lossy telemetry.
  • AckWait + MaxDeliver. If no ack arrives within AckWait, JetStream redelivers. After MaxDeliver attempts it stops (and can route to a dead-letter subject via advisories). This is what turns "at-least-once" into "at-least-once with a bounded retry".

Iconographic JetStream diagram — a stream capturing subjects to a persisted log with a retention dial, a pull consumer and a push consumer reading independent positions, and an ack policy panel showing explicit ack, AckWait redelivery, and MaxDeliver.

Worked example — a stream with a durable pull consumer

Detailed explanation. The everyday JetStream pattern is a file-backed stream plus a durable pull consumer that fetches batches and acks each message. Because the consumer is durable and the ack policy is explicit, a crash mid-batch redelivers the unacked messages on restart — the property that makes JetStream a reliable work queue.

Question. Create a stream ORDERS over orders.>, then a durable pull consumer billing that fetches a batch of messages and acknowledges each one. Show what happens to a message the consumer fetches but crashes before acking.

Input.

stream subjects retention consumer ack policy
ORDERS orders.> limits billing (durable, pull) explicit

Code.

import asyncio, nats

async def main():
    nc = await nats.connect("nats://localhost:4222")
    js = nc.jetstream()

    await js.add_stream(name="ORDERS", subjects=["orders.>"])
    # durable pull consumer: position + acks persist server-side
    sub = await js.pull_subscribe("orders.>", durable="billing")

    msgs = await sub.fetch(batch=10, timeout=5)   # ask for up to 10
    for m in msgs:
        process(m.data)        # if we crash here, no ack was sent
        await m.ack()          # explicit ack advances the ack floor

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. add_stream captures every orders.> message durably with a monotonically increasing sequence number; the publisher never sees the consumer. The durable pull consumer billing stores its ack floor server-side, so restarting the client resumes from the last acked sequence, not from zero. fetch(batch=10) pulls up to ten pending messages; the client acks each after processing. If it crashes after delivering seq 5 but before acking it, AckWait eventually expires with no ack and JetStream redelivers seq 5 on the next fetch — at-least-once. Processing must therefore be idempotent, because a redelivery can repeat work.

Output.

message delivered ≥ 1 time lost? duplicated on crash?
seq 1–4 (acked) yes no no
seq 5 (crash pre-ack) yes no possibly (redelivered)

Rule of thumb. A durable consumer + AckExplicit gives you a crash-safe work queue for free; just make the handler idempotent, because "at-least-once" means seq 5 can arrive twice.

JetStream interview question on redelivery and poison messages

Question. A consumer keeps failing on one malformed order and you are watching that message get redelivered forever, blocking progress. How do you bound the retries in JetStream and route the bad message aside instead of looping on it, without dropping healthy messages?

Solution Using MaxDeliver with a bounded AckWait and terminate

Code.

import asyncio, nats
from nats.js.api import ConsumerConfig, AckPolicy

async def main():
    nc = await nats.connect("nats://localhost:4222")
    js = nc.jetstream()
    await js.add_stream(name="ORDERS", subjects=["orders.>"])

    cfg = ConsumerConfig(
        durable_name="billing",
        ack_policy=AckPolicy.EXPLICIT,
        ack_wait=30,        # redeliver if unacked after 30s
        max_deliver=5,      # give up after 5 attempts
    )
    sub = await js.pull_subscribe("orders.>", config=cfg)

    for m in await sub.fetch(10, timeout=5):
        try:
            process(m.data)
            await m.ack()
        except BadOrder:
            await m.term()   # stop redelivery of THIS msg, keep the rest

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

attempt AckWait elapsed deliveries so far action
1 yes (no ack) 1 redeliver
2–4 yes (no ack) 2,3,4 redeliver
5 handler calls term() 5 (= max_deliver) dropped from consumer, advisory emitted
healthy msgs acked normally 1 never affected
  1. ack_wait=30 sets how long JetStream waits for an ack before assuming failure and redelivering — the redelivery clock.
  2. max_deliver=5 caps total attempts, so a message that never acks is not retried forever; after five deliveries JetStream stops offering it.
  3. Calling m.term() explicitly tells JetStream "do not redeliver this one," short-circuiting the retry loop the moment you know the message is poison.
  4. A MAX_DELIVERIES advisory is published on $JS.EVENT.ADVISORY.>, so a small subscriber can persist poison messages to a dead-letter subject for later inspection — healthy messages keep flowing throughout.

Output:

message max deliveries reached outcome
poison order 5 (or term()) removed from redelivery, sent to dead-letter
healthy orders 1 acked, progress continues

Why this works — concept by concept:

  • AckWait — the per-message redelivery timer; too short causes false redeliveries of slow work, too long delays recovery from a real crash, so it is tuned to the handler's p99.
  • MaxDeliver — a hard cap that converts "infinite redelivery" into "bounded retry", the single setting that stops a poison message from wedging the consumer.
  • term() vs nak()nak() asks for immediate redelivery (transient error), while term() refuses further delivery (permanent error); choosing correctly is what an interviewer probes.
  • Advisory dead-letter — JetStream has no built-in DLQ, but the MAX_DELIVERIES advisory lets you build one, so bad messages are quarantined, not lost.
  • Cost — bounded at O(max_deliver) attempts per poison message; healthy throughput is unaffected because redelivery only touches the unacked message.

Streaming
Topic — streaming
Durable-stream and consumer-offset problems

Practice →

Real-time Topic — real-time-analytics Ack, redelivery and at-least-once problems

Practice →


4. Exactly-once dedup, KV and object store

Dedup on the publish side, at-least-once on the consume side — plus KV and object store built on the same stream

JetStream gives you two distinct guarantees people muddle. On the publish side you can get effective exactly-once storage via message deduplication. On the consume side you get at-least-once with a double-ack option that tightens the window but never fully removes the "process twice" possibility — so idempotent handlers stay non-negotiable. On top of streams, JetStream also ships two typed abstractions — a key/value store and an object store — that are just streams with a friendlier API.

Publish-side dedup — Nats-Msg-Id.

  • Stamp each message with a Nats-Msg-Id header. If two messages carry the same id within the stream's duplicate_window (e.g. 2 minutes), JetStream stores only the first and drops the second — even across a publisher retry storm.
  • This makes producer retries safe. A publisher that resends after a lost publish-ack does not create a duplicate, as long as it reuses the same id inside the window. That is exactly-once storage.
  • The window is bounded. Dedup only covers ids seen within duplicate_window; an id repeated after the window expires is treated as new. Size the window to your worst-case retry gap.

Publish acks and double-ack.

  • Publish ack. js.publish(...) returns a PubAck with the stream and sequence number once the message is durably stored; a producer waits on it before considering the send done.
  • Double-ack on consume. m.ack_sync() (ack + confirm) makes the consumer wait for the server to record the ack, closing the gap where an ack is lost and the message is redelivered — tightening, not eliminating, duplicates.

KV store — versioned key/value on a stream.

  • A KV bucket is a stream in disguise. Keys map to subjects, values to messages; the stream keeps the last N revisions per key. put, get, update (with a revision for optimistic concurrency), and delete are the API.
  • watch gives you change streams. You can subscribe to a key or prefix and get every update — ideal for config, feature flags, and service discovery pushed to the edge.

Object store — chunked large blobs.

  • Big payloads chunked across messages. The object store splits a large blob (a firmware image, a model file) into chunks stored as stream messages, with metadata and a digest, so you can ship files through the same JetStream backbone instead of a separate blob store.

Iconographic JetStream dedup diagram — two publishes with the same Nats-Msg-Id inside a duplicate window collapsing to one stored message, a KV bucket with versioned keys, and an object store chunking a large blob across stream messages.

Worked example — a retried publish that dedups to one stored message

Detailed explanation. The producer-side exactly-once story is easiest to see with a deliberate double-publish. Send the same logical order twice with the same Nats-Msg-Id inside the duplicate window; the stream stores it once and reports the second publish as a duplicate rather than appending a new sequence.

Question. A producer publishes order ord-42 to orders.new, does not see the ack (network blip), and retries with the same Nats-Msg-Id. How many messages land in the stream, and what tells the producer the retry was a duplicate?

Input.

{"subject": "orders.new", "Nats-Msg-Id": "ord-42", "body": {"id": 42, "amount": 19.99}}
Enter fullscreen mode Exit fullscreen mode

Code.

import asyncio, nats

async def main():
    nc = await nats.connect("nats://localhost:4222")
    js = nc.jetstream()
    await js.add_stream(name="ORDERS", subjects=["orders.>"],
                        duplicate_window=120)   # 2-minute dedup window

    hdr = {"Nats-Msg-Id": "ord-42"}
    ack1 = await js.publish("orders.new", b'{"id":42}', headers=hdr)
    # network blip: producer never saw ack1, so it retries the SAME id
    ack2 = await js.publish("orders.new", b'{"id":42}', headers=hdr)

    print(ack1.seq, ack1.duplicate)   # 7 False
    print(ack2.seq, ack2.duplicate)   # 7 True

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. The stream is created with duplicate_window=120, so JetStream remembers the message ids it has seen in the last 120 seconds. The first publish stores the message at sequence 7 and returns duplicate=False. The retry carries the same Nats-Msg-Id inside the window, so the server does not append a second message; it returns the original sequence 7 with duplicate=True. The producer treats duplicate=True as success — the message is safely stored exactly once — making the publish path idempotent under retries. Only once the window has elapsed does the same id count as a genuinely new message.

Output.

outcome value
messages in stream for ord-42 1
2nd PubAck.duplicate True
producer sees retry as success (no dup created)

Rule of thumb. Set duplicate_window larger than your worst-case publish retry/backoff span and stamp a stable business id in Nats-Msg-Id; then a producer can safely resend on any timeout without ever double-writing.

JetStream interview question on KV concurrency

Question. Several edge services read and update a shared feature-flag value in a JetStream KV bucket, and two of them try to flip the same flag at once. How do you use KV so the second writer does not silently clobber the first, and how do other services learn about the change in real time?

Solution Using a revision-checked KV update with watch

Code.

import asyncio, nats

async def main():
    nc = await nats.connect("nats://localhost:4222")
    js = nc.jetstream()
    kv = await js.create_key_value(bucket="config")   # a stream underneath

    entry = await kv.get("feature.flag")              # e.g. value=b"off", revision=6
    # optimistic concurrency: only write if still at revision 6
    await kv.update("feature.flag", b"on", last=entry.revision)

    # any service can watch for live changes
    watcher = await kv.watch("feature.>")
    async for update in watcher:
        print(update.key, update.value, update.revision)

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

writer read revision update(last=…) result
A 6 last=6 ok → revision 7
B (concurrent) 6 last=6 conflict: current is 7 → error
B (retry) 7 last=7 ok → revision 8
  1. A KV bucket is a JetStream stream keyed by subject, so each key carries a monotonically increasing revision number.
  2. update(key, value, last=revision) is a compare-and-set: JetStream applies it only if the key is still at last, otherwise it raises a wrong-last-sequence error.
  3. Writer B read revision 6 but A already advanced the key to 7, so B's last=6 update is rejected — no silent clobber; B must re-read and retry against 7.
  4. kv.watch("feature.>") streams every revision change to all subscribers, so edge services react to the flip in real time without polling.

Output:

key final value final revision lost update?
feature.flag on 8 no — B retried on 7

Why this works — concept by concept:

  • KV on a stream — the bucket is a normal JetStream stream, so it inherits revisions, history, and replication; KV is an API veneer, not a separate engine.
  • Optimistic concurrencyupdate(last=rev) is compare-and-set on the revision, turning a lost-update race into an explicit conflict the loser must resolve.
  • Revision as a fence — the monotonic revision is the fencing token that makes concurrent writers safe without a distributed lock.
  • watch = change stream — subscribing to key changes pushes live config to the edge, replacing polling with an event stream that is itself durable.
  • Cost — a get + CAS update is O(1) against the key's latest revision; a conflict costs one extra read-and-retry, bounded by contention, not data size.

Events
Topic — event-processing
Message-dedup and exactly-once problems

Practice →

Real-time Topic — real-time-analytics KV-state and idempotent-write problems

Practice →


5. Edge topology and the Kafka contrast

Leaf nodes fold the edge into one subject space — and knowing where NATS beats Kafka is the decision the interview is really testing

The feature that makes NATS "edge-native" is the leaf node: a full NATS server running at the edge that connects outbound to a central cluster over a single connection and transparently extends the same subject space. An edge device publishes to factory1.temp locally and a subscriber in the cloud sees it, with no bespoke bridge — the subject namespace spans edge and core.

Leaf nodes — the edge server.

  • One outbound connection. The leaf dials the hub (usually over TLS on 7422), so the edge sits behind NAT/firewalls with no inbound ports open — a huge operational win for factories, stores, and vehicles.
  • Local-first, then forward. Messages are handled locally at the leaf (sub-ms) and only cross the link when a remote subscriber or a hub-side stream needs them, so a flaky WAN does not stall local processing.
  • JetStream at the edge. A leaf can run its own JetStream domain for local durability and mirror/source streams to the hub, so edge data survives disconnection and syncs when the link returns.

Security and multi-tenancy.

  • Accounts isolate subject spaces. Each tenant/site gets an account; subjects are private to an account unless explicitly exported/imported, so factory1 and factory2 cannot see each other's subjects by default.
  • Subject-level permissions. Users are granted publish/subscribe on specific subject patterns, so a sensor credential can publish only factory1.> and nothing else.

NATS vs Kafka — the contrast interviewers want.

  • Footprint. NATS is a ~20 MB single binary, no JVM, no external coordinator; Kafka is a JVM process plus KRaft (or legacy ZooKeeper). NATS runs where Kafka cannot.
  • Latency. Core NATS is sub-millisecond fire-and-forget; Kafka is low-millisecond and tuned for batched throughput, not per-message RPC.
  • Model. NATS routes by subject with wildcards and adds streams optionally; Kafka is a partitioned commit log where the topic-partition is the unit and ordering is per-partition.
  • Throughput & replay. Kafka is built for very high sustained per-partition throughput and long retention with a rich stream-processing ecosystem (Kafka Streams, Flink, ksqlDB); JetStream does durable replay and per-consumer positions but is not aimed at the same firehose analytics scale.
  • Extras. NATS bundles request-reply, KV, and object store in one server; Kafka needs add-ons (Connect, Schema Registry, ksqlDB) for comparable breadth.

Iconographic diagram — three edge leaf-node NATS servers folding into a central cluster over one outbound connection on the left, and a compact NATS-versus-Kafka comparison panel on the right covering footprint, latency, ordering, and replay.

Worked example — an edge leaf node forwarding to the hub

Detailed explanation. The clearest way to see leaf nodes is a minimal edge config: a leaf server that connects out to a hub and shares its subject space. A publish at the edge appears to a hub subscriber with no bridge code, and a local JetStream domain keeps the data if the WAN drops.

Question. Configure an edge NATS server as a leaf node connecting to a hub so that a sensor publishing factory1.temp at the edge is visible to a subscriber in the central cloud, while the edge keeps working if the link goes down.

Input.

node role connects local durability
hub core cluster listens streams in cloud
edge leaf node dials hub :7422 own JetStream domain

Code.

## edge-leaf.conf  — run: nats-server -c edge-leaf.conf
server_name: edge_factory1

jetstream {
  domain: edge          # local JetStream domain survives WAN loss
  store_dir: "/data/js"
}

leafnodes {
  remotes: [
    { url: "tls://hub.example.com:7422", credentials: "edge.creds" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation. The leaf server dials the hub outbound on 7422 over TLS with credentials, so no inbound port is opened at the edge site — critical behind NAT and factory firewalls. The subject space is shared: once a hub subscriber expresses interest in factory1.>, the leaf forwards matching messages upstream; with no interest, they stay local (interest-based propagation), so the WAN carries only what the cloud actually wants. The edge runs its own JetStream domain: edge, so publishes are durably stored locally even while the WAN is down. When the link returns, the edge stream mirrors/sources to the hub, so nothing published during the outage is lost.

Output.

condition edge processing cloud visibility
link up works sees factory1.> live
link down works (local + edge JS) paused
link restored works backlog syncs up

Rule of thumb. Put a leaf node wherever you need local-first messaging behind a firewall; give it its own JetStream domain so an edge outage degrades to "buffer locally and sync later," never to "data lost."

NATS interview question on choosing NATS vs Kafka

Question. An interviewer describes two systems and asks which broker fits each and why. System A: 5,000 IoT devices behind NAT sending small, frequent telemetry, needing local buffering during outages and occasional request-reply to push commands back to a device. System B: a clickstream ingesting ~2M events/sec, retained 30 days, feeding batch jobs and a Flink stream-processing topology. Which do you pick for each?

Solution Using NATS leaf nodes for A and Kafka for B

Code.

System A  → NATS + JetStream (leaf nodes at the edge)
  - devices connect to a local/regional leaf (outbound TLS, no inbound ports)
  - telemetry buffered in an edge JetStream domain during WAN loss
  - commands use core request-reply:  nc.request("dev.123.cmd", payload)
  - footprint: ~20MB per edge server, runs on constrained hardware

System B  → Apache Kafka
  - 2M events/sec sustained, ordered per partition, 30-day retention
  - partitioned topic log feeds Flink / Kafka Streams natively
  - mature connector + schema-registry ecosystem for the analytics stack
Enter fullscreen mode Exit fullscreen mode

Step-by-step trace.

requirement System A System B
footprint / edge tiny binary, behind NAT → NATS data-center JVM cluster → Kafka
request-reply / commands first-class in NATS not native to Kafka
local buffering on outage edge JetStream domain n/a (central)
sustained 2M ev/s, 30-day retain not the design point Kafka's core strength
stream-processing ecosystem lighter Flink/ksqlDB mature
  1. System A is dominated by edge constraints: devices behind NAT, intermittent WAN, small messages, and a need to send commands back. Leaf nodes + core request-reply + edge JetStream fit exactly, and the ~20 MB footprint runs on device-class hardware.
  2. System B is dominated by sustained high throughput and long retention feeding a stream-processing ecosystem — Kafka's partitioned log, ordering, and Flink/ksqlDB integrations are the design center.
  3. The senior move is refusing a one-size answer: match each system to the broker whose design point it sits on, rather than forcing both onto one.
  4. A hybrid is legitimate — NATS at the edge fronting a Kafka analytics core — but each layer is chosen for the workload it serves.

Output:

system pick deciding factor
A (IoT edge + commands) NATS/JetStream leaf nodes footprint, NAT traversal, request-reply
B (2M ev/s clickstream) Kafka sustained throughput, retention, ecosystem

Why this works — concept by concept:

  • Match the design point — NATS optimizes footprint, latency, and edge topology; Kafka optimizes sustained ordered throughput and long retention, so each system goes to the broker built for its dominant constraint.
  • Request-reply as a differentiator — System A needs command/response, a first-class NATS primitive that Kafka has no native equivalent for.
  • Edge buffering — a leaf's JetStream domain gives outage tolerance Kafka would need a separate edge tier to approximate.
  • Throughput ceiling — Kafka's per-partition log is engineered for the 2M ev/s, 30-day-retention regime that is not NATS's aim.
  • Cost — the right pick minimizes total operational cost: NATS avoids a JVM cluster at 5,000 edges; Kafka avoids re-inventing a high-throughput log — choosing wrong pays for the mismatch forever.

Streaming
Topic — streaming
Edge-messaging and topology problems

Practice →

Pipelines Topic — pipelines Broker-choice and pipeline-design problems

Practice →


Cheat sheet — NATS and JetStream recipes

Publish / subscribe (core).

await nc.subscribe("orders.new", cb=handle)
await nc.publish("orders.new", b'{"id": 1}')
Enter fullscreen mode Exit fullscreen mode

Wildcard subscribe.

await nc.subscribe("orders.*.new")   # one token:  orders.eu.new
await nc.subscribe("orders.>")       # tail:        orders.eu.new.v2
Enter fullscreen mode Exit fullscreen mode

Queue-group worker (load balance).

await nc.subscribe("jobs.>", queue="workers", cb=handle)  # each msg -> one member
Enter fullscreen mode Exit fullscreen mode

Request / reply.

reply = await nc.request("svc.time", b"", timeout=1)   # auto _INBOX reply subject
Enter fullscreen mode Exit fullscreen mode

Create a stream + durable pull consumer.

js = nc.jetstream()
await js.add_stream(name="ORDERS", subjects=["orders.>"], retention="workqueue")
sub = await js.pull_subscribe("orders.>", durable="billing")
for m in await sub.fetch(10, timeout=5):
    await m.ack()
Enter fullscreen mode Exit fullscreen mode

Dedup publish with Nats-Msg-Id.

await js.add_stream(name="ORDERS", subjects=["orders.>"], duplicate_window=120)
await js.publish("orders.new", b'{"id":42}', headers={"Nats-Msg-Id": "ord-42"})
Enter fullscreen mode Exit fullscreen mode

KV bucket.

kv = await js.create_key_value(bucket="config")
await kv.put("feature.flag", b"on")
entry = await kv.get("feature.flag")   # entry.value, entry.revision
Enter fullscreen mode Exit fullscreen mode

NATS vs Kafka — pick by workload.

Situation Pick
Edge / IoT / behind firewall, tiny footprint NATS (leaf nodes)
Request-reply / RPC as a first-class primitive NATS
Durable work queue, at-least-once, KV + objects in one binary JetStream
Very high sustained per-partition throughput, long retention Kafka
Mature stream-processing ecosystem (Flink, ksqlDB) Kafka

Frequently asked questions

What is NATS and how is it different from Kafka?

NATS is a lightweight, open-source messaging system that runs as a single ~20 MB Go binary with no JVM and no external coordinator, routing messages by dot-delimited subjects rather than partitioned topics. Core NATS is at-most-once publish/subscribe; JetStream adds durable streams with at-least-once delivery. Kafka is a partitioned, replicated commit log optimized for very high sustained throughput and long retention with a rich stream-processing ecosystem. Choose NATS for edge, real-time, small footprint, and request-reply; choose Kafka for firehose-scale ordered log throughput and analytics.

What is JetStream?

JetStream is the persistence layer built into the NATS server (enabled with -js). It captures messages published on chosen subjects into durable, append-only streams, and lets consumers replay those streams at their own position with acknowledgements, redelivery, and deduplication. On top of streams it also provides a key/value store and an object store, so one binary gives you messaging, durable queues, state, and blob storage.

What do * and > mean in NATS subjects?

Subjects are dot-delimited tokens like orders.eu.new. * is a wildcard for exactly one token, so orders.*.new matches orders.eu.new but not orders.new. > is a tail wildcard matching one or more trailing tokens, so orders.> matches orders.new, orders.eu.new, and orders.eu.new.v2, and it must be the final token. Wildcards let one subscription cover a whole subject subtree.

How does NATS deliver at-least-once?

At-least-once is a JetStream property, not core NATS. A durable consumer with the AckExplicit policy must acknowledge each message; if no ack arrives within AckWait, JetStream redelivers it, up to MaxDeliver attempts. Because a message can be delivered more than once (a redelivery after a slow or lost ack), consumer handlers must be idempotent — JetStream guarantees no loss, not automatic exactly-once on consume.

How does NATS deduplicate messages?

On the publish side, JetStream deduplicates using the Nats-Msg-Id header within a stream's duplicate_window. If two publishes carry the same id inside that window, only the first is stored and the second's PubAck comes back with duplicate=True, so producer retries after a lost ack do not create duplicates. This gives exactly-once storage; the window is bounded, so size it larger than your worst-case retry gap.

What are NATS leaf nodes for edge?

A leaf node is a full NATS server that runs at the edge and connects outbound to a central hub over a single (usually TLS) connection, transparently extending the same subject space. The edge site needs no inbound open ports, messages are handled locally first and only forwarded on interest, and a local JetStream domain keeps edge data durable through WAN outages and syncs to the hub on reconnect. This is what makes NATS practical on a Raspberry Pi, in a store, or in a vehicle.

Practice on PipeCode

Pipecode.ai is Leetcode for Data Engineering — every NATS idea above, from the queue-group worker and the durable JetStream consumer to the Nats-Msg-Id dedup and the leaf-node edge topology, maps to a hands-on practice room where you build the pipeline against real graded inputs. PipeCode pairs each reading with 450+ DE-focused problems and a real-time scoring engine, so your answer to "how would you make this consumer idempotent and at-least-once?" holds up under a senior interviewer's depth probes.

Practice streaming problems now →
Event-processing drills →

Top comments (0)