Event-Driven Architecture with NATS · Part 3

JetStream KV Is a Change Stream, Not a Table

JetStream KV looks like a key-value database, but it is a stream abstraction with a latest-value view — watch for changes, design keys as hierarchies, and treat entity types as key prefixes.

7 min read

JetStream KV looks like a distributed HashMap. It is not.

A KV bucket is a specially configured JetStream stream (KV_<bucket>). Each key is a NATS subject ($KV.<bucket>.<key>). The stored value is the latest message on that subject. For event-driven systems, the productive mental model is: watch for changes, design keys as hierarchies, treat entity types as key prefixes — not as separate buckets.

There is no WHERE clause. You query by key, key-prefix wildcards, or application-maintained index keys.

Prerequisites: Core NATS (Part 1), JetStream (Part 2). Researched against docs.nats.io and nats.go kv.go on July 6, 2026.

TL;DR

  • Bucket X → stream KV_X → subjects $KV.X.>
  • Key order.42 → subject $KV.orders.order.42 → latest message = current value
  • watch is the native reactive read path — it delivers PUT, DELETE, and PURGE events in real time
  • Entity types belong in key prefixes (orders-v1.<id>), not in separate buckets
  • KV guarantees monotonic reads/writes per key; it does not guarantee read-your-writes on follower gets
  • Secondary indexes are an application pattern — maintain index keys on write, clean up on delete

What You Will Learn Here

  • How buckets map to streams and subjects
  • Why KV is a change stream, not a queryable table
  • Key-prefix design for multi-entity buckets
  • Querying without SQL: wildcards, ListKeysFiltered, secondary-index pattern
  • Watch lifecycle: snapshot, sentinel, live updates
  • KV selection criteria — when to use it and when to pick something else
  • A concrete implementation with watch and index keys

Buckets Are Streams

When you create a KV bucket, JetStream materializes a stream:

nats kv add app-objects --history 5 --ttl 1h
nats kv status app-objects
# JetStream Stream: KV_app-objects

nats stream info KV_app-objects
# Subjects: $KV.app-objects.>
ConceptImplementation
Bucket nameapp-objects
Underlying streamKV_app-objects
Subject pattern$KV.app-objects.>
Individual key$KV.app-objects.<key>
Latest valueLatest message on that subject (MaxMsgsPerSubject, default 1)
  kv.Put("orders-v1.abc-123", json)

         v
  PUBLISH → $KV.app-objects.orders-v1.abc-123

         v
  Stream KV_app-objects (retention, replicas, limits apply)

         v
  kv.Get("orders-v1.abc-123") = last message on that subject

Key design rule: One bucket is one consistency domain. Put entity types in key prefixes, not in separate buckets — unless you have a strong isolation reason.

Bucket: app-objects  (one stream, shared replication and limits)

  orders-v1.{orderId}       ← entity type as prefix
  customers-v1.{customerId}
  idx.status.active.{orderId}        ← secondary index (app-maintained)

Watch all orders:

Subscribe / Watch: $KV.app-objects.orders-v1.>

This uses Core NATS wildcard rules from Part 1: > matches all trailing tokens.

KV as a Change Stream

The most important API for reactive systems is watch, not get.

watch subscribes to changes on matching keys. It uses an ordered consumer and delivers:

  1. Snapshot — last value per matching key (unless UpdatesOnly)
  2. Nil sentinel — signals initial snapshot is complete
  3. Live updates — PUT, DELETE, PURGE as they happen
watcher, _ := kv.Watch(ctx, "orders-v1.>", jetstream.UpdatesOnly())
defer watcher.Stop()

for entry := range watcher.Updates() {
    if entry == nil {
        continue // initial snapshot complete
    }
    switch entry.Operation() {
    case jetstream.KeyValuePut:
        log.Printf("PUT %s rev=%d", entry.Key(), entry.Revision())
        projectOrder(entry.Key(), entry.Value())
    case jetstream.KeyValueDelete, jetstream.KeyValuePurge:
        log.Printf("DEL %s rev=%d", entry.Key(), entry.Revision())
        removeOrder(entry.Key())
    }
}
                    JetStream KV Mental Model
                    ========================

  kv.Put(key, val)  ──►  subject $KV.bucket.key  ──►  stream KV_bucket

                    ┌─────────────────────────────────────────┤
                    │                                         │
                    v                                         v
              kv.Get(key)                              kv.Watch("prefix.>")
           "latest value view"                        "change stream view"
           (point read)                               (reactive projection)

Editorial judgment: If your service polls KV in a loop, you are fighting the abstraction. Watch is the native integration point for projectors and cache warmers.

Consistency model

Per official KV documentation:

  • KV guarantees immediate consistency for monotonic writes and monotonic reads per key
  • KV does not guarantee read-your-writes on direct get requests served by followers or mirrors
  • For stricter reads, route gets to the stream leader

Cross-store consistency with a primary database or any external source of truth remains eventual — KV is a projection, not truth.

Querying Without WHERE

KV has no SQL-style query API. You have three practical options:

1. Key-prefix wildcards

Design keys as dot-separated hierarchies and watch or list by prefix:

// All active orders — if keys are designed as orders-v1.{status}.{id}
watcher, _ := kv.Watch(ctx, "orders-v1.*")
// nats.js
const keys = await kv.keys("orders-v1.*");

2. ListKeysFiltered (Go jetstream package)

Go’s jetstream package (December 2024+) provides filtered key listing:

lister, _ := kv.ListKeysFiltered(ctx, "orders-v1.*")
for key := range lister.Keys() {
    entry, _ := kv.Get(ctx, key)
    process(entry.Value())
}
lister.Stop()

Note: This API is Go-centric as of July 2026. Portable alternatives use Watch with MetaOnly or keys() with wildcards in nats.js.

3. Secondary-index pattern (application-maintained)

Maintain extra keys on write that act as indexes:

func upsertOrder(ctx context.Context, kv jetstream.KeyValue, o Order) error {
    id := o.ID
    payload, _ := json.Marshal(o)

    // Primary record
    if _, err := kv.Put(ctx, "orders-v1."+id, payload); err != nil {
        return err
    }

    // Index keys — your "WHERE clause"
    idxKey := fmt.Sprintf("idx.status.%s.%s", o.Status, id)
    if _, err := kv.Put(ctx, idxKey, []byte(id)); err != nil {
        return err
    }
    return nil
}

// Query active orders
lister, _ := kv.ListKeysFiltered(ctx, "idx.status.active.*")

This is an application pattern, not a NATS feature. You must:

  • Update index keys when status changes (delete old index key, write new one)
  • Clean up index keys on delete
  • Make writers idempotent — duplicate puts should not leave stale index keys

Concrete Implementation: Watch-Driven Cache Warmer

A service that keeps an in-memory cache warm from KV changes:

import { connect, StringCodec } from "nats";

const nc = await connect({ servers: process.env.NATS_URL });
const js = nc.jetstream();
const kv = await js.views.kv("app-objects");
const cache = new Map<string, object>();
const sc = StringCodec();

const watcher = await kv.watch({ key: "orders-v1.>" });

(async () => {
  for await (const entry of watcher) {
    if (entry === null) {
      console.log("snapshot complete, cache size:", cache.size);
      continue;
    }

    const key = entry.key;
    if (entry.operation === "DEL" || entry.operation === "PURGE") {
      cache.delete(key);
      continue;
    }

    cache.set(key, JSON.parse(sc.decode(entry.value)));
  }
})();

// Fast read path — from memory, not KV
function getOrder(id: string): object | undefined {
  return cache.get(`orders-v1.${id}`);
}

Pair this with optimistic locking for coordination:

// Leader election / lock — only one worker runs the export
rev, err := kv.Create(ctx, "lock.export."+jobID, []byte(workerID))
if err != nil {
    return ErrAlreadyRunning
}
defer kv.Delete(ctx, "lock.export."+jobID)

KV Selection Criteria

Reach for KV when you need shared, persistent, watchable state with keyed access and a reactive read path (watch). Pick a database, Object Store, or raw stream when the workload needs relational queries, large blobs, unbounded history, or authoritative entity storage.

Use KV forUse something else for
Shared config and feature flagsRelational queries across arbitrary fields
Distributed locks and CAS coordinationFull-text search (→ OpenSearch)
Low-latency keyed reads of small blobsLarge file storage (→ Object Store)
Watch-driven projections and cache warmingSource of truth for business entities
Cross-service shared state with history ≤ 64 revisionsAudit log with unbounded history (→ raw stream)

KV holds projections and coordination state — not ad-hoc query surfaces or domain truth. Your primary database or domain service owns truth; KV is the watchable copy other services subscribe to.

Common Mistakes

  1. One bucket per entity type. Creates operational overhead. Use key prefixes in one bucket unless isolation requires separation.
  2. Treating get as the primary integration. Watch is the reactive path; get is for point reads.
  3. Assuming SQL semantics. No joins, no WHERE, no transactions across keys.
  4. Ignoring index maintenance. Secondary-index keys go stale without cleanup on update/delete.
  5. Confusing KV with truth. KV is a projection or cache layer. Your primary database or domain service owns truth — see Part 4.

Next in series: EDA with NATS: One Truth, Many Read Models — CQRS Projections

Sources