Getting Started

This page covers the core pub/sub surface: connecting, publishing, subscribing, wildcards, queue groups, connection events, and request/reply. Runnable versions of everything here (plus a request/reply responder and a queue-group demo) are in Basic.scala on GitHub.

Prerequisites

Start a NATS server:

docker run -p 4222:4222 nats:latest

The snippets below share these imports and helpers:

import cats.effect.IO
import com.comcast.ip4s.{Host, Port}
import fs2.Chunk
import fs2.nats.client.{ClientConfig, ClientEvent, NatsClient}
import fs2.nats.protocol.Headers
import fs2.nats.subscriptions.NatsMessage

val host = Host.fromString("localhost").get
val port = Port.fromInt(4222).get

def processWork(msg: NatsMessage): IO[Unit] = IO.println(s"processing ${msg.subject}")

Basic usage

NatsClient.connect hands you the client as a Resource; a subscription is in turn a Resource over an fs2.Stream[F, NatsMessage]. Nothing connects or subscribes until the Resource is used.

val config = ClientConfig(host = host, port = port)

val program: IO[Unit] =
  NatsClient.connect[IO](config).use { client =>
    client.subscribe("hello.world").use { messages =>
      for
        _   <- client.publish("hello.world", Chunk.array("Hello, NATS!".getBytes))
        msg <- messages.take(1).compile.lastOrError
        _   <- IO.println(s"Received: ${msg.payloadAsString}")
      yield ()
    }
  }

Instead of building Host/Port by hand, ClientConfig.localhost() covers local development and ClientConfig.fromUrl parses nats://host:4222 and tls://host:4222 URLs (credentials are configured separately — see Authentication & TLS); fromUrls takes a list of cluster seed servers:

val local  = ClientConfig.localhost()
val parsed = ClientConfig.fromUrl("nats://demo.nats.io:4222")

Publishing with headers

NATS 2.2+ headers are first class. Build a Headers value and pass it to publish:

def publishWithHeaders(client: NatsClient[IO]): IO[Unit] =
  val headers = Headers(
    "X-Request-Id" -> "abc123",
    "X-Timestamp"  -> System.currentTimeMillis().toString
  )
  client.publish("events.created", Chunk.array("""{"id": 1}""".getBytes), headers)

On the receiving side, msg.headers reads case-insensitively: get returns the first value, getAll every value of a repeated key. Headers is immutable — add appends a value, set replaces all values of a key, and remove drops it:

def requestId(msg: NatsMessage): Option[String] =
  msg.headers.get("x-request-id")

Wildcard subscriptions

* matches a single token; > matches one or more trailing tokens:

// Subscribe to all events under events.*
def singleToken(client: NatsClient[IO]): IO[Unit] =
  client.subscribe("events.*").use { messages =>
    messages.evalMap(msg => IO.println(s"${msg.subject}: ${msg.payloadAsString}")).compile.drain
  }

// Subscribe to events.a, events.a.b, events.a.b.c, ...
def multiToken(client: NatsClient[IO]): IO[Unit] =
  client.subscribe("events.>").use(_.compile.drain)

Queue groups (load balancing)

Subscribers sharing a queue group split the messages between them:

def worker(client: NatsClient[IO]): IO[Unit] =
  client.subscribe("work.queue", queueGroup = Some("workers")).use { messages =>
    messages.evalMap(processWork).compile.drain
  }

Connection events

client.events is a Stream[F, ClientEvent] reporting connection lifecycle, slow consumers, and protocol errors:

def watchEvents(client: NatsClient[IO]): IO[Unit] =
  client.events.evalMap {
    case ClientEvent.Connected(info) =>
      IO.println(s"Connected to ${info.serverId}")
    case ClientEvent.Disconnected(reason, willReconnect) =>
      IO.println(s"Disconnected: $reason, reconnecting: $willReconnect")
    case ClientEvent.Reconnected(info, attempt) =>
      IO.println(s"Reconnected to ${info.serverId} after $attempt attempts")
    case ClientEvent.SlowConsumer(sid, subject, dropped) =>
      IO.println(s"Slow consumer on $subject, dropped $dropped messages")
    case other =>
      IO.println(s"Event: $other")
  }.compile.drain

Further variants: Reconnecting(attempt, delayMs) before each attempt, ProtocolError(message, fatal), ServerInfoUpdated(info) on a fresh INFO, LameDuckMode when the server announces it is draining, and MaxReconnectsExceeded(attempts, lastError) when the client gives up.

Inspecting the connection

serverInfo exposes the server's INFO (id, version, maxPayload, whether JetStream and headers are available, ...), and isConnected reports the live connection state:

def payloadLimit(client: NatsClient[IO]): IO[Long] =
  client.serverInfo.map(_.maxPayload)

Request/Reply

request publishes to a shared response inbox and awaits a single reply. It fails fast with NatsError.NoResponders if nobody is listening (503), or NatsError.Timeout if no reply arrives within the timeout (5 seconds unless overridden); request headers ride along as a parameter:

import scala.concurrent.duration.*

def echo(client: NatsClient[IO]): IO[NatsMessage] =
  client.request("service.echo", Chunk.array("ping".getBytes))

def echoTuned(client: NatsClient[IO]): IO[NatsMessage] =
  client.request(
    "service.echo",
    Chunk.array("ping".getBytes),
    headers = Headers("X-Request-Id" -> "abc123"),
    timeout = 10.seconds
  )

The responder side is a plain subscription: a request is a message whose replyTo is set (msg.isRequest), and answering means publishing to that subject:

def echoResponder(client: NatsClient[IO]): IO[Unit] =
  client.subscribe("service.echo").use { requests =>
    requests.evalMap { req =>
      req.replyTo match
        case Some(reply) => client.publish(reply, req.payload)
        case None        => IO.unit
    }.compile.drain
  }

(publish also takes a replyTo parameter for wiring the pattern manually.) For request/reply with typed payloads, typed errors and discovery, see Micro Services.

Next up: JetStream for persistence and consumers.