Skip to content
English

NATS

NatsActor integrates with NATS — the lightweight pub/sub broker. Core NATS is fire-and-forget without durability; for durable streams, see the JetStream variant.

import { ActorSystem } from 'actor-ts';
import { NatsActor, NatsOptions } from 'actor-ts/io';
const natsOptions = NatsOptions.create()
.withServers(['nats://nats-1:4222', 'nats://nats-2:4222'])
.withName('my-app');
const nats = system.spawn(
() => new NatsActor(
natsOptions,
),
'nats',
);
// Subscribe (wildcards supported):
nats.tell({
kind: 'subscribe',
subject: 'events.>',
target: eventHandler,
});
// Publish:
nats.tell({
kind: 'publish',
publish: {
subject: 'events.user.signup',
payload: JSON.stringify(event),
},
});
interface NatsOptionsType extends BrokerCommonOptionsType {
servers: string[] | string;
name?: string; // client identifier
user?: string;
password?: string;
token?: string;
subscriptions?: { subject: string; target: ActorRef<NatsMessage> }[]; // wired up at connect time
tls?: TlsTransportOptionsType; // cert material; also enables TLS; code-only
}

subscriptions and the runtime subscribe command feed the same desired set, held by the BrokerActor base rather than by the connection. Practical consequences:

  • Every subscription — configured or added at runtime — is re-established on the new connection after a reconnect.
  • A subscribe sent while the actor is disconnected is applied on the next connect instead of being dropped.
  • { kind: 'unsubscribe', subject } removes the subject for good: it does not come back on the next reconnect, even if it came from subscriptions.
  • Re-subscribing a live subject swaps its target — the previous subscription is dropped first, so exactly one actor receives it.
// Added at runtime; still there after the broker restarts.
nats.tell({ kind: 'subscribe', subject: 'audit.>', target: auditor });
// Point the same subject at a different actor.
nats.tell({ kind: 'subscribe', subject: 'audit.>', target: newAuditor });
// Gone for good.
nats.tell({ kind: 'unsubscribe', subject: 'audit.>' });

Note that this is about the subscription, not the messages: core NATS has no durability, so anything published during the outage is still gone (see the caution below).

NATS uses .-delimited subjects:

events.user.signup
events.user.delete
orders.priority.placed
metrics.gauge.cpu

Subjects are case-sensitive; per convention they’re lowercase + dot-separated.

WildcardMatches
*Exactly one token (matches user in events.user.signup).
>One or more tokens (matches user.signup in events.user.signup).
'events.>' → events.user.signup, events.user.delete, events.order.placed
'events.*.signup' → events.user.signup, events.admin.signup

> can only appear as the last token.

Core NATS models request/reply with a reply subject: the requester publishes with a replyTo subject and listens on it; the responder sends its answer there. There’s no dedicated request command — you compose it from publish / subscribe and the replyTo field.

// Requester — listen on a private reply subject, then publish the
// request with `replyTo` pointing at it:
nats.tell({ kind: 'subscribe', subject: 'reply.balance.42', target: replyHandler });
nats.tell({
kind: 'publish',
publish: {
subject: 'account.balance',
payload: JSON.stringify({ accountId: '42' }),
replyTo: 'reply.balance.42', // responder answers on this subject
},
});
// Responder — subscribe to the request subject, answer on `replyTo`:
class BalanceService extends Actor<NatsMessage> {
constructor(private readonly nats: ActorRef<NatsCommand>) { super(); }
override onReceive(message: NatsMessage): void {
if (!message.replyTo) return;
this.nats.tell({
kind: 'publish',
publish: {
subject: message.replyTo,
payload: JSON.stringify({ balance: 100 }),
},
});
}
}

Inbound NatsMessages carry subject, payload (a Uint8Array), and replyTo (an empty string when the publisher set none). This is the “RPC over NATS” pattern — a few hundred microseconds round-trip on localhost.

NATS has no tls:// URL scheme, so withTls does both jobs here: it asks nats.js to negotiate TLS and says what the handshake should trust. mTLS is the common production posture:

const natsOptions = NatsOptions.create()
.withServers(['nats://nats.example.com:4222'])
.withTls({
ca: fs.readFileSync('./tls/ca.crt', 'utf8'),
cert: fs.readFileSync('./tls/client.crt', 'utf8'),
key: fs.readFileSync('./tls/client.key', 'utf8'),
});
new NatsActor(natsOptions);

Every field carries the material itself, never a path to it, which is why the example reads the files. cert and key only mean anything together, and one without the other is rejected when the actor starts. Omit withTls entirely and the connection is plaintext unless the server forces TLS, in which case nats.js verifies it against the system trust store.

JetStreamActor takes the identical option. Neither has a config key on purpose: a private key does not belong in application.conf.

Three primary use cases:

  1. High-throughput pub/sub without the operational complexity of Kafka.
  2. Microservices request/reply — synchronous calls between services via subjects.
  3. Lightweight event distribution — fire-and-forget notifications, metrics, log streams.

For durable streams (replay, history, ack semantics), see JetStream below. For cluster-internal pub/sub, DistributedPubSub is simpler.

For NATS with durability, use JetStreamActor:

import { JetStreamActor, JetStreamOptions } from 'actor-ts/io';
const jetStreamOptions = JetStreamOptions.create()
.withServers(['nats://nats-1:4222'])
.withStream({
name: 'ORDERS',
subjects: ['orders.>'],
storage: 'file',
maxAge: 86_400 * 7 * 1_000_000_000, // 7 days (maxAge is in nanoseconds)
});
const js = system.spawn(
() => new JetStreamActor(
jetStreamOptions,
),
'js',
);

JetStream layers:

  • Stream — durable log, subjects → records.
  • Consumer — read cursor; multiple consumers can read the same stream independently.
  • Ack semantics — like AMQP, consumers ack each message.

Use JetStream when you need:

  • Replay — consumers can rewind to any offset.
  • Persistence — survives broker restart (file-backed storage).
  • Ordered consumption per subject.

Kafka still beats JetStream for massive scale (billions of events / sec across hundreds of partitions). JetStream is the sweet spot for “less than Kafka, more than core NATS.”

Terminal window
npm install nats
# or: bun add nats

The nats package includes both core NATS and JetStream clients.