LazyLayersv0.5.3
GuidesTransports

NATS Core

One small binary, the fastest fan-out, and the same at-most-once trade as Redis.

A single binary with no dependencies and nothing on disk. Core mode stores nothing, so there is no stream to size, no queue to reap and no backlog to manage.

Delivery is at-most-once, the same trade Redis Pub/Sub makes. The server hands a message to whoever is subscribed at that moment and forgets it. A server that is restarting, paused or briefly partitioned misses those invalidations and reloads each affected key on its next request.

The bus subscribes to the subject directly, with no queue group, so every subscriber receives every message. That is what makes it fan-out rather than work distribution.

Run NATS

.env
NATS_URL="nats://localhost:4222"

nats://demo.nats.io is a public server run for testing. Anyone can subscribe to any subject on it, so your cache keys and every value the lazy fan-out broadcasts are visible to strangers. Use it to check that a client connects, never for anything real.

Set it up

Redis for L2

NATS carries the events. Redis still holds the shared values.

src/cache/redis.js
import Redis from 'ioredis'

export const redis = new Redis(process.env.REDIS_URL, {
  maxRetriesPerRequest: 2,
  enableReadyCheck: true,
  enableOfflineQueue: true,
})

redis.on('error', (err) => {
  console.error('[redis]', err.message)
})

The bus

src/cache/bus.js
import { NatsEventBus } from 'lazy-layers-cache'

export const bus = new NatsEventBus({
  mode: 'core',
  connectionOptions: {
    servers: process.env.NATS_URL,
    // Only affects what the server reports, but it makes
    // `nats server report connections` readable.
    name: process.env.INSTANCE_ID,
  },
  subject: 'lazylayers.invalidate',
  retryQueue: { maxSize: 1000 },
  logging: { env: 'production' },
})

await bus.connect()

connect() opens the connection and flushes it, so a wrong URL or bad credentials fail at startup rather than on your first invalidation.

The cache

src/cache/index.js
import { LazyLayersCache, RedisStore } from 'lazy-layers-cache'
import { redis } from './redis.js'
import { bus } from './bus.js'

export const cache = new LazyLayersCache({
  ttlMs: 5 * 60 * 1000,
  levels: {
    L1: { maxEntries: 10_000, ttlMs: 60 * 1000 },
    L2: { ttlMs: 60 * 60 * 1000 },
  },
  l2: new RedisStore(redis, { prefix: 'app:cache:', deleteStrategy: 'unlink' }),

  eventBus: bus,
  source: process.env.INSTANCE_ID ?? `srv-${process.pid}`,
  broadcastSetMaxBytes: 32 * 1024,
})

Read and invalidate

src/users/repository.js
import { cache } from '../cache/index.js'
import { db } from '../db.js'

export async function getUser(id) {
  return cache.getOrSet(`user:${id}`, async ({ signal } = {}) => {
    const row = await db.users.findById(id, { signal })
    return row ?? undefined
  })
}

export async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch)
  await cache.delete(`user:${id}`)
  return updated
}

Every server must publish and subscribe on the same subject. Servers on different subjects do not see each other and nothing warns you.

Share a connection you already have

If your application already holds a NATS connection, hand it over rather than opening a second one.

src/cache/bus.js
import { connect } from '@nats-io/transport-node'
import { NatsEventBus } from 'lazy-layers-cache'

export const nats = await connect({ servers: process.env.NATS_URL })

export const bus = new NatsEventBus({
  mode: 'core',
  connection: nats,
  subject: 'lazylayers.invalidate',
})

await bus.connect()

The bus records that it did not create this connection. disconnect() then drains the subscription and leaves the connection open, so your application keeps control of the one it made. When you pass connection, connectionOptions is unused.

Every option

mode('core' | 'jetstream')default: core

Core mode is at-most-once and stores nothing. Set it to jetstream for durable delivery, covered in NATS JetStream.

subject(string)default: cache.invalidations

Subject used to publish and subscribe. Identical across every server sharing invalidation.

connectionOptions(NodeConnectionOptions)

Passed straight to connect() from @nats-io/transport-node. Anything that driver accepts works here: servers, name, token, credentials files, TLS material, reconnectTimeWait. Unused when you pass connection.

connection(NatsConnection)

An existing connection to share. The bus will not drain a connection it did not create.

jetstream(NatsJetStreamOptions)

Read only in jetstream mode. Setting it while mode is core has no effect.

retryQueue.enabled(boolean)default: true

Buffer an event in memory when the publish fails, and flush the buffer ahead of the next successful publish. Core mode gets no acknowledgement from any subscriber, so without this a publish that fails while the connection is down is simply gone.

retryQueue.maxSize(number)default: 10000

Cap on buffered events. Past it, the oldest is dropped with a warning.

logging(CacheLoggerOptions)

{ env, enabled }. enabled wins when set, otherwise output is on unless env is 'production'.

Failure modes

A successful publish means the server took the message, not that anyone got it. The bus publishes and then awaits a flush, which confirms the message reached the NATS server. Zero connected subscribers is still a successful publish.

A disconnected subscriber misses everything sent while it was gone. There is no backlog and no replay. On reconnect the server resumes from live traffic, and every key invalidated during the gap stays in its L1 until the L1 TTL expires it. Short L1 TTLs are how you bound that, which is why an L1 TTL of a minute against an L2 TTL of an hour is a sensible shape.

Handlers run one at a time. The subscription loop awaits each handler before pulling the next message, so ordering is preserved as delivered, and one slow handler delays every event behind it. There is no concurrency option in core mode.

A handler that throws loses that event. The error is logged and the loop continues to the next message. Nothing is retried, because there is nothing stored to retry from.

A message that does not decode is logged as a warning and skipped, so an unrelated publisher on the same subject cannot corrupt your cache.

Repeated publish failures trip the cache's circuit breaker, and then the retry queue stops helping. Once the breaker is open the cache returns before it calls eventBus.publish, so nothing reaches the retry queue to be buffered. See the sharp edges.

Operational notes

If the connection has closed, the next publish opens a new one from connectionOptions. A bus you constructed with a shared connection that has since closed will reconnect using connectionOptions instead, so set both if you share a connection and expect long uptime.

The NATS CLI is the fastest way to tell whether the problem is your code or your connection. Subscribe to the subject with no application involved:

nats sub 'lazylayers.invalidate'

Delete a key on one server and you should see the event print immediately. If the CLI sees it and a peer does not, the problem is on the peer: check that it uses the same subject, and that its source differs from the publisher's.

From inside the application, watch the cache's own event stream:

src/cache/watch.js
import { cache } from './index.js'

cache.on((event) => {
  if (event.type === 'invalidation:received') {
    console.log('received', event.eventType, event.eventId)
  }
})

When to choose it

Choose NATS Core when you want fan-out on infrastructure built for it, you are happy with the at-most-once trade, and you would rather not put invalidation traffic through the same Redis that serves your L2.

Choose Redis Pub/Sub instead if you do not already run NATS. The delivery semantics are the same and it is one fewer component.

Move to NATS JetStream when a server coming back from a restart must not serve a value that was deleted while it was down. The same server handles both, so it is a -js flag and a configuration change rather than new infrastructure.

Where to next

On this page