Core NATS is the lightweight messaging layer underneath most NATS deployments. It routes messages by subject, fans out with publish/subscribe, supports RPC-style calls through request/reply, and load-balances work through queue groups — all with at-most-once, fire-and-forget delivery and no server-side persistence.
That last point matters. If a subscriber is offline when a message is published, the message is gone. Core NATS is fast and decoupled, not durable. When you need replay, retention, or guaranteed redelivery, you graduate to JetStream (Part 2).
This article was researched against docs.nats.io on July 6, 2026.
TL;DR
- Core NATS is interest-based pub/sub: messages are delivered only to active subscribers on matching subjects; if nobody is listening, the message is discarded.
- Delivery is best-effort, at-most-once — messages are held in memory, never written to disk.
- Subjects are hierarchical strings separated by
.tokens; wildcards (*= one token,>= one or more trailing tokens) apply on subscribe, not publish. - Request/reply gives you RPC-style call semantics over async pub/sub: the client publishes a request with a reply inbox and blocks until one response arrives or a timeout fires.
- Queue groups load-balance among members of the same group; plain (non-queue) subscribers on the same subject still receive every message.
- Use Core NATS for live fan-out, internal RPC, and worker pools. Use JetStream when events must survive subscriber downtime.
What You Will Learn Here
- What Core NATS is — and what it is not
- How subject hierarchies and wildcards work, including reserved
$namespaces - Publish/subscribe fan-out semantics
- Request/reply as RPC over messaging
- Queue groups for load balancing
- A pattern selection guide: symptom → pattern → delivery guarantee
- When to move from Core NATS to JetStream
For where NATS fits in a growing system architecture, see The Pieces of Modern, Effective Software Design.
What Core NATS Is (and Is Not)
NATS separates publishers (who send messages) from subscribers (who receive them). The server routes by subject name — no broker-side topic creation step, no schema registry required at the protocol level.
Core NATS characteristics, per official documentation:
| Property | Core NATS behavior |
|---|---|
| Delivery guarantee | Best-effort, at-most-once |
| Storage | In-memory only; never written to disk |
| Subscriber requirement | Interest-based — no active subscriber means message discarded |
| Fan-out | Every matching plain subscriber receives the message |
| Replay | Not supported |
JetStream (Part 2) adds persistence, durable consumers, and at-least-once delivery on top of this transport.
Subjects and Wildcards
A subject is a dot-separated string that identifies a message channel. The . token creates hierarchy:
orders.created.us-east
orders.created.eu-west
orders.shipped.us-east
Wildcard rules
Publishers always use fully specified subjects — no wildcards in publishes. Subscribers use wildcards to scope interest:
| Wildcard | Matches | Example subscription | Matches | Does not match |
|---|---|---|---|---|
* | Exactly one token | orders.*.us-east | orders.created.us-east | orders.created.eu-west |
> | One or more trailing tokens; must be last | orders.> | orders.created.us-east | billing.invoice |
Wildcards resemble regex but are more restrictive: * cannot match inside a token, and > must be the final token.
Reserved $ subjects
Subjects starting with $ are reserved for system use — $SYS, $JS, $KV, and others. Application traffic should not use $ prefixes unless you are intentionally interacting with a NATS subsystem.
JetStream KV (covered in Part 3) stores keys under subjects like $KV.<bucket>.<key>. Wildcard rules from Core NATS apply when watching a bucket:
Bucket: app-objects
Key: orders-v1.abc-123
Subject: $KV.app-objects.orders-v1.abc-123
Watch all order keys:
Subscribe: $KV.app-objects.orders-v1.>
Breaking that down: $KV is reserved, app-objects is the bucket name, orders-v1 is a key prefix (entity type), and > matches all order IDs under that prefix.
Publish/Subscribe: Fire-and-Forget Fan-Out
Pub/sub is the default Core NATS pattern. One publisher, many subscribers, no acknowledgment protocol.
import { connect, StringCodec } from "nats";
const nc = await connect({ servers: "nats://localhost:4222" });
const sc = StringCodec();
// Two independent consumers — both get every message
nc.subscribe("orders.created", {
callback: (_err, msg) => console.log("analytics:", sc.decode(msg.data)),
});
nc.subscribe("orders.created", {
callback: (_err, msg) => console.log("email:", sc.decode(msg.data)),
});
await nc.publish("orders.created", sc.encode(JSON.stringify({ id: "ord-42" })));
await nc.flush();
Teaching point: If neither subscriber is connected when the message is published, the message is discarded. There is no replay, no dead-letter queue, no ack. Design accordingly — or use JetStream.
┌─────────────────────────────────────┐
│ NATS Server │
│ (in-memory, no persistence) │
└─────────────────────────────────────┘
▲ ▲
publish │ │
orders.created ────────┘ │
│
┌─────────────────────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
[Analytics] [Email] [no subscriber]
plain sub plain sub → message lost
gets ALL gets ALL
Request/Reply: RPC Over Pub/Sub
Request/reply is not a separate protocol. It is pub/sub with a reply address:
- Client publishes a request on a subject, setting a unique inbox as the reply subject.
- A responder receives the request and publishes the response to that inbox.
- Client libraries wrap this as a blocking
request()call with a timeout.
// Client — RPC-style call with timeout
msg, err := nc.Request("billing.invoice.get", []byte(`{"id":"inv-99"}`), time.Second)
if err == nats.ErrNoResponders {
// No service listening — fail fast (503 when no_responders is enabled)
log.Fatal("no billing service available")
}
fmt.Println(string(msg.Data))
// Responder
nc.Subscribe("billing.invoice.get", func(msg *nats.Msg) {
// ... look up invoice ...
msg.Respond([]byte(`{"id":"inv-99","total":1500}`))
})
Wording that matters: This gives you RPC-style call semantics. The transport remains async pub/sub. Timeouts, ErrNoResponders, and responder availability are your responsibility.
Multiple responders can join a queue group on the request subject for horizontal scale — one random member handles each request.
Queue Groups: Built-In Load Balancing
Queue groups distribute work among a set of subscribers sharing the same queue name. NATS delivers each message to exactly one member of the group, chosen at random.
// Three workers sharing the same queue — each message goes to one worker
for (let i = 0; i < 3; i++) {
nc.subscribe("jobs.render", {
queue: "processors",
callback: (_err, msg) => console.log(`worker-${i}:`, sc.decode(msg.data)),
});
}
Critical detail: Queue groups and plain subscribers coexist on the same subject.
Subject: jobs.render
[Worker A] queue:"processors" ─┐
[Worker B] queue:"processors" ─┼─ each message → ONE queue member
[Worker C] queue:"processors" ─┘
[Monitor] plain subscriber ─── every message (for observability)
If you add a plain subscriber for monitoring alongside queue workers, it still receives all messages. Only members of the same queue group share work.
Queue groups are dynamic — no server configuration. Scale by starting or stopping application instances. Use drain-before-exit so in-flight messages complete on scale-down.
When to Use Each Primitive
| Symptom | Core NATS pattern | Delivery guarantee |
|---|---|---|
| Notify multiple services when something happens | Pub/sub fan-out | At-most-once; offline subscribers miss messages |
| Call a service and wait for an answer | Request/reply | At-most-once; timeout if no responder |
| Spread jobs across N workers | Queue group | At-most-once; one worker per message |
| Never lose an event | Not Core NATS | → JetStream Part 2 |
| Replay events after a bug fix | Not Core NATS | → JetStream durable consumers |
Concrete Implementation: Choosing a Pattern
Here is a small service that uses all three primitives for an order flow:
import { connect, StringCodec } from "nats";
const nc = await connect({ servers: process.env.NATS_URL });
const sc = StringCodec();
// 1. Fan-out: notify analytics and email on every order
nc.subscribe("orders.created", {
callback: (_err, msg) => publishToAnalytics(JSON.parse(sc.decode(msg.data))),
});
// 2. Load-balance: PDF generation across workers
nc.subscribe("orders.pdf", {
queue: "pdf-workers",
callback: async (_err, msg) => {
const order = JSON.parse(sc.decode(msg.data));
await renderPdf(order);
},
});
// 3. RPC: look up inventory synchronously during checkout
nc.subscribe("inventory.check", {
queue: "inventory-service",
callback: (_err, msg) => {
const sku = JSON.parse(sc.decode(msg.data));
const available = lookupStock(sku);
msg.respond(sc.encode(JSON.stringify({ available })));
},
});
// Checkout flow
async function checkout(cart: Cart) {
const stock = await nc.request(
"inventory.check",
sc.encode(JSON.stringify(cart.items)),
{ timeout: 2000 },
);
// ... charge, create order ...
await nc.publish("orders.created", sc.encode(JSON.stringify({ id: "ord-99" })));
await nc.publish("orders.pdf", sc.encode(JSON.stringify({ id: "ord-99" })));
}
Design note: Because Core NATS is at-most-once, the orders.created publish after a successful charge is a risk point. If no subscriber is listening, you lose the notification. For business-critical events, publish through JetStream instead.
Common Mistakes
- Assuming delivery. Core NATS does not buffer for offline subscribers. If it matters, use JetStream.
- Treating queue groups as exclusive. Plain subscribers on the same subject still get every message.
- Using wildcards in publishes. Publishers must use concrete subjects; wildcards are subscribe-side only.
- Confusing request/reply with HTTP. There is no built-in status code layer beyond
ErrNoResponders. Timeouts, retries, and error envelopes are application concerns. - Skipping idempotency. At-most-once can also mean duplicates in failure scenarios at the application layer. Design handlers to tolerate retries even on Core NATS.
Core NATS vs JetStream: When to Graduate
Need Core NATS JetStream
─────────────────────────────────────────────────────────────
Live fan-out, all online ✓ optional
RPC with timeout ✓ —
Worker pool load balancing ✓ (queue groups) ✓ (work-queue streams)
Subscriber was offline ✗ message lost ✓ replay from stream
Guaranteed processing ✗ ✓ ack + redeliver
Key-value shared state ✗ ✓ KV buckets (Part 3)
Next in series: NATS JetStream: When Events Need to Survive the Moment
Sources
- Core NATS — pub/sub model and at-most-once delivery
- What is NATS? — QoS and fire-and-forget semantics
- Publish-Subscribe — fan-out and message structure
- Subject-Based Messaging — hierarchy, wildcards, reserved names
- Request-Reply — inbox pattern and no responders
- Queue Groups — load balancing semantics
- Request-Reply Semantics (developer guide)
- Queue Subscriptions (developer guide)
- nats-io/nats.go — kv.go subject templates —
$KV.<bucket>.>layout