How to schedule delayed messages
One-shot delayed message delivery powered by NATS 2.12 message scheduling (ADR-51).
Publish an event now, have it delivered to the consumer at a future time. Replaces the need for external schedulers like Bull or Agenda for simple delayed jobs.
Requirements
- NATS Server >= 2.12
allow_msg_schedules: trueon the event stream
Configuration
Enable message scheduling on the event stream:
JetstreamModule.forRoot({
name: 'orders',
servers: ['nats://localhost:4222'],
events: {
stream: { allow_msg_schedules: true },
},
});
This flag is not enabled by default to maintain backward compatibility with NATS < 2.12. It can be safely added to existing streams — NATS applies it as a regular update without recreation or downtime.
Usage
Use scheduleAt() on JetstreamRecordBuilder to delay delivery:
import { JetstreamRecordBuilder } from '@horizon-republic/nestjs-jetstream';
import { lastValueFrom } from 'rxjs';
// Deliver in 1 hour
const record = new JetstreamRecordBuilder({ orderId: 42, type: 'reminder' })
.scheduleAt(new Date(Date.now() + 60 * 60 * 1000))
.build();
await lastValueFrom(this.client.emit('order.reminder', record));
The consumer handles it like any normal event — no changes needed on the receiving side:
@EventPattern('order.reminder')
handleReminder(@Payload() data: OrderReminder) {
// Executed at the scheduled time
}
How it works
scheduleAt(date)stores the delivery time in the record- On publish, the library routes to a per-message unique
_schsubject within the event stream (a library convention to separate scheduled messages from regular events). The unique suffix matters: the server stores schedules as rollup messages — one active schedule per subject (ADR-51) — so a shared subject would silently replace a pending schedule on every publish - The publish includes
Nats-ScheduleandNats-Schedule-Targetheaders (ADR-51) — the server uses these headers, not the subject, to manage scheduling - NATS holds the message until the scheduled time, then publishes a new message to the target event subject and purges the fired schedule holder, so unique
_schsubjects do not accumulate - The event consumer processes it normally
Important: max_age consideration
Scheduled messages are stored in the event stream like any other message. If the stream's max_age expires before the scheduled delivery time, NATS silently purges the message — no delivery, no error.
The default event stream max_age is 7 days. If you need to schedule messages further in the future, increase max_age:
events: {
stream: {
allow_msg_schedules: true,
max_age: 0, // no expiry — use when scheduling beyond 7 days
},
},
Setting max_age: 0 disables automatic cleanup for all messages in the event stream, not just scheduled ones. Consider the storage implications for high-throughput streams.
Scheduling with a custom subject prefix
When a custom subjectPrefix is configured for an event or broadcast kind, the schedule holders live under {prefix}_sch. instead of the default {service}__microservice._sch. prefix:
| Prefix configuration | Schedule holder prefix |
|---|---|
| No custom prefix (default) | {service}__microservice._sch. |
subjectPrefix: 'company.orders.' | company.orders._sch. |
If the stream is externally managed (ManagementMode.Manual), it must cover this prefix in its subjects list. Boot fails with an explicit error if the coverage is missing:
Stream "…" has scheduling enabled (allow_msg_schedules=true) but its subjects do not cover the schedule prefix "…". Add "…>" to the stream's subjects.
For example, a stream with subjects: ["company.orders.>"] already covers company.orders._sch.> because the wildcard > matches any suffix. No additional entry is needed.
If you used a prefix like company.orders.events. (narrower wildcard), you must add company.orders._sch.> explicitly or widen the wildcard.
See Bring Your Own Infrastructure — Scheduling with a custom prefix for a full provisioning example.
Limitations
- One-shot only. No cron or interval scheduling. NATS supports these (ADR-51), but the library currently exposes only
scheduleAt()for one-shot delivery. - Workqueue events and broadcasts only.
scheduleAt()is ignored for RPC (client.send()) with a logged warning, and throws forordered:patterns — the schedule holder cannot live in the same stream as an ordered target, which the server requires. - Future dates only.
scheduleAt()throws if the date is not in the future. - NATS >= 2.12.
allow_msg_schedulesis not supported by older server versions. max_ageconstraint. Schedule delay must not exceed the stream'smax_age; the pending schedule is an ordinary stored message and expires with the stream's retention. Note the broadcast stream default is 1 hour: scheduling a broadcast further out requires raising itsmax_age.- Per-stream opt-in. Broadcast scheduling requires
allow_msg_schedules: trueon the broadcast stream config separately. ttl()applies to the delivered message. Combining.ttl()with.scheduleAt()setsNats-Schedule-TTL: the countdown starts when the scheduled message fires, not when it is published.
See also
- Record Builder & Deduplication —
JetstreamRecordBuilder.scheduleAt()in the full builder API - Per-Message TTL — sibling feature for per-message expiration
- Naming Conventions — Scheduling subjects — how the transport routes scheduled messages internally
- Default Configs —
allow_msg_schedulesin the enable-only stream properties table