Skip to content

Why an embedded server is not a compromise

Most "embedded" things are a reduced version of a real thing, kept for tests. An embedded database that cannot replicate, an embedded queue that loses everything on restart. You use one knowing you will replace it.

NATS is not that, and the difference is what this module is built on.

There is no embedded mode

nats-server is a Go library. The Options struct you pass to NewServer when embedding carries Cluster, Gateway, LeafNode, Routes, JetStream, StoreDir, JetStreamKey and DontListen — the same fields a standalone deployment sets in a configuration file.

There is no reduced surface, no feature flag, no "not available when embedded". Embedding is constructing the server in your own process, and everything a standalone server can do, this one can: cluster, replicate with RAFT, persist to disk, encrypt at rest, serve leaf nodes.

That is verifiable rather than promised. It is the same struct.

What that buys

A progression where each step is configuration rather than a rewrite:

  1. One process, no socket. InProcessOnly, and the client takes an in-memory net.Conn. No TCP, not even loopback. Nothing outside the process can reach the broker whatever the network allows.
  2. One process, a listener. Drop InProcessOnly, set Host and Port. Other processes on the box can now join.
  3. A mesh. Add ClusterName and Routes. Three servers and you have RAFT quorum, so JetStream can replicate.
  4. Somebody else's cluster. Delete the server entirely and set ClientSettings.URL.

Every publisher and subscriber is identical across all four. They were always talking to a NATS client, never to a channel, so the address is the only thing that changed.

Why this is different from swapping a pub/sub abstraction

A pub/sub abstraction library gives you one interface over many backends, and the migration is choosing a different adapter. That works, and it has a cost: the abstraction has to express what every backend can do, so it either takes the intersection — publish bytes to a name — or it leaks one backend's semantics into all of them.

The commonly-cited Go example leaks. Its in-process backend blocks the publisher until the subscriber receives, and requires an acknowledgement before the next message is sent, because those are a broker's semantics borrowed by a Go channel. That is fine when you want a broker, and wrong when you want a bounded channel.

Here the semantics are the broker's from the first line, because it is a broker. Nothing has to be translated later, because nothing was pretending.

What it costs

Honestly: eleven dependencies and a real broker's worth of code in your binary, to move messages between two goroutines.

Against one small service that looks absurd. Against an estate where the alternative is each project hand-rolling a queue, and then a second one, and then discovering they need to talk to each other — it is infrastructure, paid for once.

If a cluster already exists, import nats/client alone and none of that cost applies. Most services should.