This post is the narrow, honest answer to whether you can drop Redis from your stack when atomic counters and hot key-value lookups are the workloads you run it for. Sometimes yes. Sometimes no. The goal here is to make the trade-off legible so you can decide.
TL;DR. Atomic distributed counters and a microsecond-latency key-value (KV) store are the two things teams most often add Redis to do — and NATS now does both natively. Increment-in-place counters, introduced in NATS server 2.12 (September 2025), let you publish a delta such as +1 or -20; the server increments a counter kept per subject inside a stream and serializes every update, so all writers succeed with no read-modify-write race and no client retries. Counters use arbitrary precision and can even be summed across multiple streams or accounts via sourcing. The JetStream key-value store answers an in-cache get in roughly 40 microseconds plus the network round trip on low-end cloud hardware — within single-digit microseconds of Redis on the same hardware, and faster than Redis for writes in those tests — while remaining persistent and replicated. The honest limits: NATS KV is fast as long as the working set fits in memory, NATS is built for streaming rather than random access, and Redis still offers far richer native data structures. But if counters and hot key-value lookups are the reason you run Redis, NATS can let you run one fewer system.
Everything below expands that paragraph.
Only for a specific pair of jobs.
Redis is a mature in-memory data store with a rich native vocabulary — lists, sets, sorted sets, hashes, Redis streams, publish/subscribe (pub/sub), and Lua scripting. If your design leans on those data structures, none of what follows will move you off Redis, and it should not. That is the tool for that job.
The claim in this post is narrower. For atomic counters and hot key-value lookups, NATS has two primitives that cover the workload: increment-in-place counters and the JetStream KV store. Both are open-source NATS (Apache-2.0). If those are the two reasons a separate Redis deployment exists in your architecture, the messaging fabric you already run may already do the work — with persistence and multi-region replication as part of the deal.
We will map the capabilities side by side, walk through the counters primitive, share the KV latency numbers with their conditions attached, and then be plain about where NATS is the wrong choice.
Start with the concession, because the rest of the argument depends on trusting it.
Redis ships with richer native data structures than NATS: lists, sets, sorted sets, hashes, Redis streams, pub/sub, and server-side Lua scripting. If you use ZADD/ZRANGE for leaderboards, SADD/SINTER for set intersection, hashes for structured records, or Lua for multi-key atomic transforms, Redis is doing real work for you that NATS does not attempt to replicate. NATS gives you counters and a key-value store. That is the vocabulary. It is deliberately small.
So the honest way to read this post is: if your Redis usage is INCR/DECRBY for tallies and quotas, and GET/SET for sessions, feature flags, config, or a hot cache — read on. If it is sorted sets and Lua — keep Redis.
Use an increment-in-place counter, introduced in NATS server 2.12.0 (API level 2, September 2025) and specified in ADR-49.
The idea: instead of a client reading a value, adding to it, and writing it back, the client publishes a delta — +1, +20, -5 — and the NATS server increments a counter kept per subject inside a stream. A read returns the current total. There is no client-side read-modify-write loop, because the arithmetic happens on the server.
Mechanically, a publisher sets the Nats-Incr header on a message with a value of the form +N or -N, where N is a big integer. The backing stream must be created with AllowMsgCounter: true (JSON allow_msg_counter), and it also requires AllowDirect: true and Limits retention. These options are set at creation only — a stream cannot be flipped into counter mode later, and once enabled, every subject in the stream becomes a counter. Counters cannot be a Mirror and are incompatible with per-message TTL.
The Orbit client-extension libraries are the recommended way to work with counters from application code. Orbit is open-source NATS, where new features land first before graduating into the core clients; it is available across Go, Java, .NET, JavaScript, Python, Rust, and C, though not every feature is in every language yet. The Go module is called counters and lives under orbit.go.
1// Requires NATS server 2.12.0+ (API level 2). Go, Orbit `counters` module.2// The backing stream must be created with AllowMsgCounter: true3// (json `allow_msg_counter`) AND AllowDirect: true, Limits retention,4// at creation only (read-only after; cannot be a Mirror; incompatible5// with per-message TTL). Bind the counter to that stream, then:6counter, _ := counters.NewCounterFromStream(stream) // stream is a jetstream.Stream7counter.AddInt(ctx, "events.clicks", 1) // publish a +1 delta8entry, _ := counter.Get(ctx, "events.clicks") // *Entry; entry.Value is *big.Int9// counter.Add(ctx, subject, *big.Int) for arbitrary-precision deltas;10// counter.GetMultiple(ctx, []string{"events.>"}) to read many (wildcards allowed).A few things to notice. Deltas are arbitrary precision — the counter is a big integer, so it does not silently overflow a 64-bit bound. You can read a single subject with Get, or a group of subjects (wildcards allowed) with GetMultiple. And because counters live per subject in a stream, subject hierarchies map naturally to counter hierarchies: events.clicks.us-east-1, events.clicks.eu-west-2, and so on.
Yes, by construction.
The server serializes updates to a given counter subject. There is no client-side read-modify-write; every publisher sends a delta and every delta lands. Compare that to the classic client loop many teams end up writing on a plain KV:
1Read-modify-write (client-side): Server-side increment (NATS counters):2
3 client A: GET v = 42 client A: PUB events.clicks Nats-Incr:+14 client B: GET v = 42 client B: PUB events.clicks Nats-Incr:+15 client A: SET v = 43 (via CAS) client C: PUB events.clicks Nats-Incr:+16 client B: SET v = 43 (CAS FAILS) ---------------------------------------7 client B: retry: GET v = 43 server serializes: 42 -> 43 -> 44 -> 458 client B: SET v = 44 (via CAS) every writer succeeds, no retriesThe left side works, but under contention it burns retries. The right side does not have that failure mode, because the ordering happens where the state lives.
That property — server-side serialization, no client retries — is the reason to reach for counters over a hand-rolled increment on top of a plain KV.
Both are legitimate. Pick by collision rate.
Compare-and-set (CAS) on a KV key is optimistic: read the value with a revision, write conditionally on that revision, and retry if someone else wrote first. It is cheap when writers rarely collide. It is expensive — and unfair to whichever writer keeps losing the race — when many writers hit the same key.
Increment-in-place counters serialize updates on the server. They win when many writers hit the same key, because there is nothing to retry. They cost you the flexibility of a general read-modify-write (a counter only increments; it does not let you compute a new value from the old one arbitrarily).
A rough rule:
+N / -N → increment-in-place counter.Rate limiters, quotas, view/like/vote tallies, and inventory decrements are the classic counter cases. Session updates and feature-flag toggles are the classic CAS cases.
Yes. Counters are sourcing-compatible.
JetStream streams can source messages from other streams — including across NATS accounts. Because counter deltas are just messages carrying a Nats-Incr header, they source cleanly. That means you can keep a per-region counter local to that region for latency, and source the deltas into a global stream whose counter is the correct global total. Arithmetic is preserved across every source, and because the counter is arbitrary precision, the aggregate cannot silently overflow.
A representative shape: a multi-region SaaS keeps events.clicks counters per region for cheap local writes, and a global stream sources from each region so an operator dashboard can read a single subject and see the whole world.
The JetStream KV store is a key-value view over a JetStream stream. Writes are messages; reads use JetStream’s direct-get path.
On low-end cloud hardware, an in-cache get is around 40 microseconds plus the network round trip. On the same hardware, that is within roughly 5–10 microseconds of Redis for reads, and faster than Redis for writes. On disk, reads are still sub-millisecond — around 1 to 1.5 milliseconds depending on the disk, with NVMe at the lower end.
A few caveats worth stating in the same breath as the numbers:
The headline is not “NATS is faster than Redis.” It is “NATS KV is in the same latency neighborhood as Redis for hot key-value work, without the operational cost of running a second system.”
Both, by choice.
A KV bucket is a view over a JetStream stream, and streams can be backed by memory storage or file storage. Memory storage gives you the pure-speed path; file storage gives you durability. Either way, when the stream is replicated, so is the KV — it is persistent and replicated in a way a bare in-memory cache is not.
For single-replica KV or streams, there is an asynchronous persist mode (set at creation only) that avoids flushing to disk on every operation. That is the write-speed lever when you do not need per-write durability but still want the data on disk.
The practical upshot: you do not have to choose between “fast cache” and “durable store” at deployment time. You choose per bucket, per stream.
Stated plainly:
If any of those matches your workload, keep Redis. That is the correct call.
| Need | Redis | NATS | Notes |
|---|---|---|---|
| Atomic increment/decrement | INCR / DECRBY, atomic in-memory | Increment-in-place counters (NATS server 2.12.0): publish a delta via the Nats-Incr header (+N/-N); server increments per subject in a stream | NATS serializes server-side — no client read-modify-write, no retries |
| Concurrency model for hot keys | Single-threaded engine serializes commands | Server serializes counter updates; no client retries | NATS counters shine when many writers hit the same key |
| Optimistic concurrency | WATCH/MULTI (CAS-style) | Compare-and-set on a KV key (optimistic; retry on collision) | Use CAS when same-key collisions are rare |
| Counter precision | 64-bit integer | Arbitrary precision (big integers) | NATS counters will not silently overflow a 64-bit bound |
| Aggregating counters across regions/tenants | Manual app-side aggregation | Sourcing-compatible: deltas on different streams/accounts source together and still sum | Built-in multi-source roll-up |
| Hot key-value get latency | In-memory, microsecond-class | In-cache get ~40 µs + round trip on low-end cloud hardware | Within ~5–10 µs of Redis on same hardware |
| Write latency | Very fast in-memory | Faster than Redis for writes in those tests | Async persist mode helps single-replica writes |
| Persistence of KV | Optional (RDB/AOF) | Persistent and replicated by default; memory storage optional for pure speed | KV is a view over a JetStream stream |
| Richer data structures | Lists, sets, sorted sets, hashes, streams, pub/sub, Lua | Counters + key-value only | Keep Redis if you need these |
| Random access / random deletes | Designed for it | Not optimized — NATS is built for streaming; random updates do random deletes (slow) | Working set must fit in memory for top KV speed |
| One fewer system to run | Separate deployment | KV + counters run on the NATS fabric you already operate | Consolidation, not feature parity |
Can NATS replace Redis? For atomic counters and hot key-value lookups, yes — NATS has native primitives for both. For the full Redis vocabulary (sorted sets, hashes, Lua, pub/sub patterns tied to those types), no. Match the primitives to your workload.
How do you build an atomic counter without Redis? Use NATS increment-in-place counters (server 2.12.0+). Publish a delta such as +1 via the Nats-Incr header to a subject in a counter-enabled stream. The server increments the per-subject counter atomically. Read with a get.
Are NATS atomic counters race-free? Yes. The server serializes updates per subject, so there is no client read-modify-write and every writer succeeds without retries.
How fast is the NATS key-value store compared to Redis? An in-cache get is about 40 microseconds plus the network round trip on low-end cloud hardware — within roughly 5–10 microseconds of Redis on the same hardware, and faster than Redis for writes in those tests. On disk, reads are sub-millisecond.
When should you use a counter vs. compare-and-set? Use a counter when many writers hit the same key — server-side serialization avoids retries. Use CAS when same-key collisions are rare and the update is more than an increment.
What can Redis do that NATS cannot? Redis has richer native data structures: lists, sets, sorted sets, hashes, Redis streams, pub/sub, and Lua scripting. If your design uses those, keep Redis.
Is NATS KV in-memory or persistent? Both, by choice. A KV bucket is a view over a JetStream stream, which can be backed by memory or file storage, and can be replicated. You decide per bucket.
counters module)Want help from the NATS experts? Meet with our architects to get help tailored to your use case and environment.



News and content from across the community