Event-Driven Architecture with NATS · Parte 2

NATS JetStream: cuando los eventos necesitan sobrevivir al momento

JetStream agrega streams durables y estado de entrega para los consumidores, de modo que publicadores y suscriptores puedan desacoplarse en el tiempo: captura, reproducción y reentrega hasta recibir un acuse.

10 min de lectura

Core NATS es excelente para distribuir mensajes en tiempo real a múltiples destinatarios, pero no puede garantizar la entrega si un suscriptor está desconectado. JetStream agrega streams durables (almacenes de mensajes) y consumidores (máquinas de estados de entrega) para que productores y consumidores puedan desacoplarse en el tiempo: los mensajes se capturan, se reproducen bajo demanda y se reentregan hasta recibir un acuse.

Este artículo explica cómo funcionan los streams y consumidores en la práctica, con advertencias claras sobre la durabilidad para tomar decisiones en producción.

Requisito previo: Core NATS (Parte 1). Investigación basada en docs.nats.io el July 6, 2026.

TL;DR

  • JetStream agrega entrega al menos una vez (exactamente una vez es opcional) sobre el transporte de Core NATS.
  • Un stream es un almacén durable de mensajes vinculado a uno o más subjects; captura los mensajes publicados en los subjects que coinciden.
  • Un consumidor es una vista con estado de un stream: lleva registro de qué mensajes se entregaron y recibieron un acuse.
  • Los consumidores pull durables son la opción predeterminada recomendada para proyectos nuevos: ack explícito, control de flujo y obtención escalable de lotes.
  • MaxDeliver no es una cola de mensajes fallidos: después del máximo de intentos, NATS deja de reentregar el mensaje, pero este permanece en el stream.
  • Diseña consumidores idempotentes sin importar la política de ack.

Qué aprenderás aquí

  • Qué agrega JetStream a Core NATS
  • Cómo funcionan los streams: vinculación de subjects, políticas de retención y límites
  • Cómo funcionan los consumidores: durables frente a efímeros, push frente a pull, filtros y ajuste de ack
  • Una guía rápida de configuración de consumidores (AckWait, MaxDeliver, MaxAckPending)
  • Un ejemplo de extremo a extremo con outbox → JetStream → proyector
  • Cómo se relacionan los buckets KV con los streams (KV_app-objects)
  • Advertencias operativas sobre durabilidad en producción

Core NATS frente a JetStream

AspectoCore NATSJetStream
EntregaComo máximo una vez, sin garantíasAl menos una vez por defecto; exactamente una vez es opcional
AlmacenamientoEn memoria, sin discoEn archivo o en memoria, con retención configurable
Suscriptores desconectadosEl mensaje se pierdeReproducción desde el stream al reconectarse
Acoplamiento temporalEl publicador y el suscriptor deben coincidir en el tiempoDesacoplados por diseño
ReproducciónNo disponibleEntrega a partir de una secuencia, una marca de tiempo o la última posición
  Core NATS                         JetStream
  ─────────                         ─────────
  Publisher ──► Subscriber          Publisher ──► Stream ──► Consumer ──► Worker
              (must be online)                    (persisted)   (tracks cursor)

JetStream está integrado en nats-server. Actívalo en la configuración del servidor y usa las API de publicación de JetStream: el servidor confirma cuando el mensaje se almacena correctamente.

Streams: el registro durable

Un stream es un almacén de mensajes que se vincula a uno o más subjects (admite comodines). Los mensajes publicados en los subjects que coinciden se capturan según las políticas de retención y los límites.

Crear un stream

nats stream add ORDERS \
  --subjects "orders.>" \
  --retention limits \
  --storage file \
  --replicas 3 \
  --max-age 7d \
  --discard old
ConfiguraciónQué controla
subjectsQué mensajes publicados se capturan
retentionlimits (predeterminado, reproducción), workqueue (eliminar al recibir un ack), interest (retener mientras los consumidores tengan interés)
storagefile (predeterminado) o memory
replicas1–5 para clustering y alta disponibilidad
max_age / max_msgs / max_bytesLímites de retención
discardold (predeterminado) o new cuando se alcanzan los límites

Prefiere las API de publicación de JetStream a la publicación directa de Core NATS: el servidor devuelve un acuse que confirma que el mensaje se almacenó.

Los buckets KV son streams

Los buckets KV de JetStream se implementan como streams llamados KV_<bucket>. Un bucket app-objects se materializa como el stream KV_app-objects:

nats kv add app-objects --history 5 --ttl 1h
nats stream info KV_app-objects   # subjects: $KV.app-objects.>

Esto importa porque KV hereda los límites, la replicación y el comportamiento de almacenamiento de los streams. La Parte 3 explica la abstracción KV; por ahora, recuerda que un bucket es un stream con una vista del valor más reciente.

Consumidores: máquinas de estados de entrega

Un consumidor es una vista con estado de un stream. Lleva registro de qué mensajes se entregaron y recibieron un acuse. Los mensajes sin acuse se reentregan cuando vence AckWait.

Durables frente a efímeros

TipoComportamientoCuándo usarlo
DurableEstado persistente; sobrevive a reinicios; cursor compartido por nombreWorkers y proyectores en producción
EfímeroSin estado persistente; se elimina automáticamente tras un período de inactividadDepuración, reproducción puntual, dashboards temporales

Define siempre un durable_name para los consumidores de producción.

Push frente a pull

TipoComportamientoRecomendación
PullEl cliente solicita lotes bajo demandaOpción predeterminada para proyectos nuevos: mejor control de flujo, escalabilidad y manejo de errores
PushEl servidor entrega a un DeliverSubjectIntegraciones heredadas; MaxAckPending es el único mecanismo de control de flujo

FilterSubjects

Los consumidores pueden filtrar qué mensajes del stream reciben:

{
  "durable_name": "search-projector",
  "filter_subject": "orders.created",
  "ack_policy": "explicit",
  "ack_wait": 30000000000,
  "max_deliver": 5,
  "max_ack_pending": 100,
  "deliver_policy": "all"
}

El stream captura orders.>, pero este consumidor solo ve orders.created. Un consumidor por cada necesidad de los sistemas downstream mantiene independientes a los proyectores.

Guía rápida para ajustar ack

ParámetroQué controlaSugerencia de ajuste
AckWaitTiempo de espera para la reentrega después de la entregaAuméntalo para API externas lentas (por ejemplo, operaciones bulk de OpenSearch)
MaxDeliverMáximo de intentos de reentrega por mensajeDefine un límite finito y una alerta; planifica la reproducción manual de mensajes problemáticos
MaxAckPendingLímite de mensajes en tránsito sin ackRedúcelo para workers con alta latencia; principal control de flujo para push
DeliverPolicyDesde dónde comienza la reproducciónDeliverNew para mensajes en tiempo real; DeliverAll para procesamiento histórico
BackOffIntervalos crecientes entre reentregasTiene prioridad sobre AckWait cuando está configurado

Advertencia operativa: Después de MaxDeliver intentos, NATS deja de reentregar el mensaje, pero este permanece en el stream. No hay una cola de mensajes fallidos automática. Tu proceso operativo o aplicación debe mover o eliminar estos mensajes, o generar una alerta.

                        PUBLISH PATH
                        ------------
  Producer App
       |
       |  JetStream publish (orders.created)
       v
  +------------------+                            +---------------------------+
  |   NATS Server    | -------------------------> |  Stream: ORDERS           |
  |   (JetStream)    |     captures orders.>      |  retention: limits        |
  +------------------+                            |  storage: file, R=3       |
       ^                                          |  [msg1][msg2][msg3]...    |
       |  publish ack                             +---------------------------+
       |                                                    |
       |                                                    |  N consumer views
       |                              +---------------------+---------------------+
       |                              |                     |                     |
       |                    +---------v---------+ +---------v---------+
       |                    | Consumer (durable) | | Consumer (durable) |
       |                    | pull, filter:      | | pull, filter:      |
       |                    | orders.created     | | orders.shipped     |
       |                    | MaxAckPending: 100 | | MaxDeliver: 5      |
       |                    +---------+---------+ +---------+---------+
       |                              |                     |
       |                              v                     v
       |                    Search Projector        Shipping Worker
       |
       +--- if no ack within AckWait --> redeliver (up to MaxDeliver)

Implementación concreta: outbox → JetStream → proyector

Esto amplía el pipeline del modelo de lectura de Las piezas del diseño de software moderno y eficaz con semánticas de publicación y consumo específicas de JetStream.

Flujo de escritura: almacena el cambio de negocio y la fila del outbox en una sola transacción:

BEGIN;

INSERT INTO todos (id, user_id, title, completed)
VALUES (:id, :user_id, :title, false);

INSERT INTO outbox_events (id, topic, payload, created_at)
VALUES (:event_id, 'todo.created', :payload, now());

COMMIT;

Publicador: lee el outbox, publica en JetStream y marca como publicado:

import { connect, StringCodec } from "nats";

const nc = await connect({ servers: process.env.NATS_URL });
const js = nc.jetstream();
const sc = StringCodec();

async function publishOutboxBatch(events: OutboxEvent[]) {
  for (const event of events) {
    const ack = await js.publish(event.topic, sc.encode(JSON.stringify({
      id: event.id,
      occurredAt: new Date().toISOString(),
      data: event.payload,
    })));

    // Server confirmed storage in stream
    console.log(`stored seq=${ack.seq} stream=${ack.stream}`);
    await markPublished(event.id);
  }
}

Proyector: consumidor pull durable con ack explícito:

const sub = await js.pullSubscribe("todo.created", {
  config: { durable_name: "search-projector" },
  stream: "TODOS",
});

for (;;) {
  const msgs = await sub.fetch(10, { expires: 5000 });
  for (const msg of msgs) {
    const event = JSON.parse(sc.decode(msg.data));

    if (await alreadyProcessed(event.id)) {
      msg.ack();
      continue;
    }

    try {
      await openSearch.index({
        index: "todos",
        id: event.data.todo_id,
        document: {
          title: event.data.title,
          owner_id: event.data.user_id,
          completed: false,
        },
      });
      await markProcessed(event.id);
      msg.ack();
    } catch (err) {
      msg.nak(); // redeliver after AckWait
    }
  }
}

La estructura se mantiene estable entre distintos stacks:

  • Escribe una sola vez en la fuente de verdad
  • Publica los cambios de forma confiable a través de JetStream
  • Actualiza los modelos de lectura en segundo plano
  • Haz que los proyectores sean idempotentes (elimina duplicados por event.id)
  • Mide el retraso entre la escritura y la visibilidad en lectura

Advertencias sobre durabilidad (lee esto antes de pasar a producción)

JetStream es durable, no mágico. Pon en contexto las afirmaciones sobre durabilidad en las revisiones operativas y los análisis posteriores a incidentes:

AdvertenciaDetalle
Entrega predeterminadaAl menos una vez: diseña consumidores idempotentes
Exactamente una vezEs opcional mediante IDs de mensaje del publicador y deduplicación; aun así, contempla duplicados poco frecuentes
fsync en discoEl sync_interval predeterminado es de 2 minutos: un ack no siempre significa que el mensaje esté inmediatamente en disco ante todos los tipos de falla
MaxDeliver agotadoEl mensaje permanece en el stream; no se enruta automáticamente a una DLQ
Acks tardíosUn ack enviado después de que el mensaje se reentregó puede ignorarse; maneja los errores antes de enviar el ack

Elegir entre Core NATS y JetStream

NecesidadCore NATSStream de JetStreamStream de cola de trabajo
Distribución en tiempo real a múltiples destinatarios, todos los suscriptores conectados✓opcional—
El suscriptor estaba desconectado✗✓ reproducción✓
Procesamiento garantizado con reintentos✗✓ ack + reentrega✓
Cada mensaje procesado exactamente una vez✗consumidores idempotentes✓ (con salvedades)
Cola de trabajos compartidagrupos de cola—✓ retención workqueue

Errores comunes

  1. Tratar el ack como “trabajo completado en disco”. Comprende la replicación y sync_interval según tus requisitos de RPO.
  2. Suponer que MaxDeliver = DLQ. Planifica el manejo explícito de mensajes problemáticos.
  3. Usar consumidores push por defecto. Los consumidores pull ofrecen mejor control de flujo para servicios nuevos.
  4. Un solo consumidor para todo. Usa FilterSubject para darle a cada proyector su propio cursor y política de reintentos.
  5. Omitir la idempotencia. La entrega al menos una vez implica que los duplicados son normales, no excepcionales.

Siguiente en la serie: JetStream KV es un stream de cambios, no una tabla

Fuentes