Skip to main content

Ordered Events

Use whenevery replica needs to rebuild its own state from a strict, replayable event sequence (CQRS read models, in-memory caches, projections).

You getephemeral ordered consumers: each replica reads the full stream in order, independently.

A projection that applies delivered before shipped is wrong, and one that misses a message diverges silently. Workqueue events balance load across instances, which is the opposite of what a replay needs. Ordered events give strict sequence, full replay, and an independent view per instance.

How ordered consumers differ​

An ordered consumer is ephemeral and lives in the client, not on the server: it will not appear in nats consumer ls, and it is recreated from scratch whenever the connection drops. It acks for you, so handler code never calls msg.ack() or msg.nak(), and the router pins its concurrency limit to one, so the next message waits for your handler to settle.

The stream behind it keeps messages under Limits retention for a day by default instead of deleting them on ack, which is what lets every instance read the same history independently. The full comparison with workqueue events is at the end of this page.

If the consumer disconnects, the transport re-establishes it through a defer() + repeat() loop with backoff from 100ms up to 30 seconds. See self-healing consumers.

At-most-once delivery​

Nothing triggers a retry. The consumer always advances:

OutcomeWhat happens
Handler resolvesClient acks, next message
Handler throwsError logged, next message
Decode errorError logged, message skipped
No handler for the subjectError logged, message skipped

Retrying would block every later message, because order has to hold. One poison message would stop the pipeline. That is the trade-off strict ordering buys.

A throw loses the message

The message is neither redelivered nor dead-lettered, and ctx.retry() and ctx.terminate() are ignored with a warning. Catch errors inside the handler if a failure needs to reach a dead letter table or a retry queue.

Publishing and handling​

The ordered: prefix routes to the ordered stream and is stripped from the subject, which becomes {name}__microservice.ordered.order.status. Handlers register without the prefix, using the { ordered: true } flag.

src/orders/orders.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { lastValueFrom } from 'rxjs';

@Injectable()
export class OrdersService {
constructor(@Inject('orders') private readonly client: ClientProxy) {}

async shipOrder(orderId: number) {
await lastValueFrom(
this.client.emit('ordered:order.status', {
orderId,
status: 'shipped',
timestamp: new Date().toISOString(),
}),
);
}
}
src/projections/projections.controller.ts
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';

@Controller()
export class ProjectionsController {
constructor(private readonly projections: ProjectionService) {}

// Arrives in publish order, one at a time
@EventPattern('order.status', { ordered: true })
async handleOrderStatus(@Payload() data: OrderStatusDto) {
await this.projections.applyStatusChange(data);
}

@EventPattern('order.created', { ordered: true })
async handleOrderCreated(@Payload() data: OrderCreatedDto) {
await this.projections.createProjection(data);
}
}

Module side, with a week of history to replay from:

src/app.module.ts
import { Module } from '@nestjs/common';
import { JetstreamModule, toNanos } from '@horizon-republic/nestjs-jetstream';

@Module({
imports: [
JetstreamModule.forRoot({
name: 'projections',
servers: ['nats://localhost:4222'],
ordered: {
stream: { max_age: toNanos(7, 'days') },
},
}),
JetstreamModule.forFeature({ name: 'projections' }),
],
})
export class AppModule {}

Deliver policies​

The policy decides where a consumer starts reading when created or recreated, and it changes behaviour on every restart.

PolicyStarts atAfter a restartUse for
All (default)Oldest message in the streamReplays everything within max_ageRead models, event sourcing
NewStream head at creationSkips whatever arrived while downLive dashboards, metrics
LastMost recent messageDelivers the latest again, then liveSingle-value config caches
LastPerSubjectLatest message of each subjectSame as first startPer-entity state maps
StartSequenceconsumer.opt_start_seqDepends on the offset you storeResumable projections
StartTimeFirst message at or after a timeReplays from the same timestampDebugging, time-based recovery

All needs idempotent handlers, since a restart after a week replays a week. New loses messages published during downtime. LastPerSubject groups by the full NATS subject, so publishing to one subject makes it behave like Last. Publish to ordered:order.status.{orderId} for per-entity delivery.

Configuring each policy
import { DeliverPolicy } from '@nats-io/jetstream';

// New: live only
ordered: { consumer: { deliver_policy: DeliverPolicy.New } }

// Last, or LastPerSubject for per-entity
ordered: { consumer: { deliver_policy: DeliverPolicy.LastPerSubject } }

// StartTime, fixed or computed
ordered: {
consumer: {
deliver_policy: DeliverPolicy.StartTime,
opt_start_time: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
},
}

StartSequence reads the offset your application stored, so it belongs in forRootAsync():

JetstreamModule.forRootAsync({
name: 'projections',
imports: [ConfigModule, OffsetModule],
inject: [ConfigService, OffsetService],
useFactory: (config: ConfigService, offsets: OffsetService) => ({
servers: [config.getOrThrow('NATS_URL')],
ordered: {
consumer: {
deliver_policy: DeliverPolicy.StartSequence,
opt_start_seq: offsets.getLastProcessedSequence('projections'),
},
},
}),
})

Store the sequence as part of handling the message:

@EventPattern('order.status', { ordered: true })
async handleOrderStatus(@Payload() data: OrderStatusDto, @Ctx() ctx: RpcContext) {
await this.projections.apply(data);

// Ordered events are always JetStream, so a sequence is always present.
const sequence = ctx.getSequence();

if (sequence !== undefined) {
await this.offsetStore.save('projections', sequence);
}
}

With idempotent handlers and transactional offset storage, StartSequence is the only policy that reaches exactly-once processing.

Match max_age to the replay you need

DeliverPolicy.All can only replay what the stream still holds. Messages past max_age are deleted and unreachable.

Stream and replay configuration​

Field reference lives in OrderedEventOverrides, defaults in Default Configs.

import { DeliverPolicy, ReplayPolicy } from '@nats-io/jetstream';
import { toNanos } from '@horizon-republic/nestjs-jetstream';

ordered: {
stream: {
max_age: toNanos(7, 'days'), // how far back a replay can reach
max_bytes: 4 * 1024 * 1024 * 1024, // storage cap, reserved against the account
},
consumer: { replay_policy: ReplayPolicy.Original }, // default Instant
}

ReplayPolicy.Instant delivers history as fast as it can and is almost always right. Original reproduces the publishing rate, which matters for simulation and testing.

Scaling​

Every instance runs its own consumer and receives every message, without load balancing or consumer groups.

That is the design. Ordered consumers build per-instance state, such as a cache, a projection or an in-memory index. For exactly one handler across the cluster, run one replica, since the transport has no leader election. Distributed exclusive processing with ordering needs workqueue events over a single partition, or coordination outside the transport.

Because instances share the stream and restarts replay it, handlers have to be idempotent:

@EventPattern('order.status', { ordered: true })
async handleOrderStatus(@Payload() data: OrderStatusDto) {
// Upsert with a guard, never a blind insert
await this.db.query(
`INSERT INTO order_projections (order_id, status, updated_at)
VALUES ($1, $2, $3)
ON CONFLICT (order_id) DO UPDATE
SET status = $2, updated_at = $3
WHERE order_projections.updated_at < $3`,
[data.orderId, data.status, data.timestamp],
);
}

Workqueue or ordered​

Workqueue eventsOrdered events
Delivery guaranteeAt-least-onceAt-most-once
Message orderingNot guaranteed (parallel)Strict sequential
Handler parallelismConcurrent, bounded by concurrencyOne message at a time
Retry on failureYes, nak triggers redeliveryNo, error logged and skipped
Dead letter queueYes, after max_deliver attemptsNo
AcknowledgmentExplicit msg.ack()Automatic by the client
Stream retentionWorkqueue, delete on ackLimits, delete by age or size
Consumer typeDurable, server-sideEphemeral, client-side
Scaling modelLoad-balanced across instancesEvery instance gets every message
Replay on restartNo, acked messages are goneYes, per deliver policy
Publish prefix(none)ordered:
Typical use caseWorkload distribution, task queuesEvent sourcing, projections, audit logs

Reach for workqueue when processing must be guaranteed and order does not matter. Reach for ordered when sequence is the point and every instance needs the whole stream.

Caveats​

deliver_policy is missing for All. Passing All explicitly makes the SDK's consume() hang, so the transport omits the field when the policy is All or unset, which the SDK treats the same. nats consumer info shows no deliver_policy for that reason. Details in DeliverPolicy.All workaround.

No partial replay. A reconnect recreates the consumer at the position the deliver policy dictates, not at the last message processed. Resumable processing needs StartSequence with your own offset storage.

The stream name is derived. It follows naming conventions as {name}__microservice_ordered-stream and cannot be set directly.

Next​