TL;DR: NATS 2.12 introduces the JetStream Message Scheduler, which lets you publish a message with a schedule header and have the server deliver it at a future time — no external scheduler or consumer workaround required.
Watch the video for a quick walkthrough, or keep reading for the full breakdown.
Consider an order processing system. A customer places an order, but fulfillment should not begin until a payment hold clears in 30 minutes. Or a notification service that needs to send a reminder email at 9 AM tomorrow. In both cases, the message exists now, but it should not be delivered now.
Before NATS 2.12, there was no native mechanism for delayed delivery. The common workaround was NakWithDelay on a consumer — a negative acknowledgement that tells the server to redeliver a message after a specified delay. But NakWithDelay was designed for retry logic, not scheduling. It ties up consumer state, and it does not scale well when you have thousands of pending scheduled messages.
Other message brokers handle this differently. Amazon SQS offers delay queues natively. RabbitMQ supports delayed delivery through a plugin. Kafka has no built-in per-message delay at all. NATS needed a solution that was server-side, per-message, and header-driven — keeping the client interface simple.
Starting in NATS 2.12, JetStream — the persistence layer in NATS that stores messages in durable, replayable streams — includes a built-in Message Scheduler. You publish a message with scheduling headers, and the server holds it until the specified time. No external cron job, no consumer polling loop, no application-level timer.
The scheduler supports three modes: single delayed messages (2.12+), recurring cron-like schedules (2.14+), and subject sampling (2.14+).
All scheduling is configured through message headers. There is no separate scheduling API or configuration endpoint beyond enabling the feature on a stream.
The primary header is Nats-Schedule, which defines when the message should be delivered. It supports four formats:
| Format | Example | Available Since |
|---|---|---|
| One-time timestamp | @at 2026-04-08T14:00:00Z | 2.12 |
| Cron expression (6 fields) | 0 30 9 * * mon-fri | 2.14 |
| Fixed interval | @every 5m | 2.14 |
| Predefined alias | @hourly, @daily, @weekly | 2.14 |
The cron format uses six fields — seconds, minutes, hours, day-of-month, month, day-of-week — extending the standard five-field Unix cron (which dates back to 1975 at AT&T Bell Labs) with a seconds field for finer precision.
Additional headers control where and how the message is delivered:
| Header | Purpose |
|---|---|
Nats-Schedule-Target | The subject where the generated message is delivered. Must differ from the schedule subject. |
Nats-Schedule-Source | For subject sampling: reads the latest message from this subject instead of using the schedule message body (2.14+). |
Nats-Schedule-TTL | Sets a TTL (time-to-live) — a countdown after which the generated message expires — on the delivered message. Requires AllowMsgTTL on the stream. |
Nats-Schedule-Time-Zone | Timezone for cron evaluation, e.g., America/New_York. Defaults to UTC. Only applies to cron expressions, not @at timestamps (2.14+). |
Each schedule occupies its own unique subject. The most recent message on a subject is the active schedule. Publishing a new message to the same schedule subject replaces the previous schedule.
This means scheduling multiple independent delayed messages requires unique subjects. A common pattern is appending a UUID: schedules.orders.<uuid>.
When a schedule fires, the server generates a new message on the target subject with two additional headers:
Nats-Scheduler — the subject of the original schedule message, for traceabilityNats-Schedule-Next — either the next execution timestamp (for recurring schedules) or "purge" (for single delayed messages, meaning the schedule has been removed)For single @at messages, the schedule is automatically purged after delivery. For recurring schedules (cron, @every), the schedule message persists and continues producing output.
The scheduler requires explicit opt-in at the stream level:
1stream, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{2 Name: "SCHEDULED_ORDERS",3 Subjects: []string{"schedules.>", "orders"},4 AllowMsgSchedules: true,5 AllowMsgTTL: true, // Required if using Nats-Schedule-TTL6})Set AllowMsgSchedules to true in the stream configuration. Once enabled, this setting cannot be reverted. It also cannot be enabled on source or mirror streams.
A recommended pattern is to include both schedule subjects (schedules.>) and delivery subjects (orders) in the same stream, using wildcard patterns to organize schedules by domain.
nats stream add SCHED_DEMO \ --subjects "schedules.>,orders" \ --storage file \ --allow-schedules \ --allow-msg-ttl \ --defaults
nats pub schedules.orders.single '{"id": 1, "item": "widget"}' \nats pub schedules.orders.single '{"id": 1, "item": "widget"}' \ -H "Nats-Schedule:@at 2026-05-01T14:00:00Z" \ -H "Nats-Schedule-Target:orders" \ -H "Nats-Schedule-TTL:5m" \ --jetstreamThis creates a schedule-enabled stream, then publishes a message that will be delivered to the orders subject at 14:00 UTC on April 8, 2026. The generated message will have a 5-minute TTL.
1msg := nats.NewMsg("schedules.orders.demo1")2msg.Data = []byte(`{"id": 1, "item": "widget"}`)3msg.Header.Set("Nats-Schedule", "@at "+deliverAt)4msg.Header.Set("Nats-Schedule-Target", "orders")5msg.Header.Set("Nats-Schedule-TTL", "1m")6
7ack, err := js.PublishMsg(ctx, msg)The nats.NewMsg constructor creates a message with headers. Set the schedule headers, publish to a schedule subject, and the server handles delivery at the specified time. The PublishMsg method on the JetStream interface accepts *nats.Msg directly.
nats pub schedules.reports.hourly '{"report": "status"}' \ -H "Nats-Schedule:@hourly" \ -H "Nats-Schedule-Target:reports" \ --jetstreamThe server produces a message on reports at the start of every hour. The schedule message persists on its subject and fires indefinitely until explicitly purged.
Deferred Order Processing — Schedule an order message for future fulfillment. Publish with @at <timestamp> to hold it until payment clears or a release window opens. The message is delivered once, and the schedule is automatically purged.
Periodic Report Generation — Use @daily or a custom cron expression to trigger report-generation workflows at regular intervals. The schedule persists until you remove it, so there is no external cron daemon to manage.
Sensor Data Downsampling — High-frequency IoT sensors publish hundreds of readings per second. Use subject sampling with @every 5m to capture the latest reading at a manageable interval. The Nats-Schedule-Source header tells the server which subject to sample from — no changes to the sensor application required.
Retry with Backoff — Instead of using NakWithDelay on a consumer, publish a retry message with @at <future_timestamp> to a schedule subject. The Message Scheduler handles far more concurrent schedules than consumer-based retry patterns.
Past-dated schedules fire immediately — Publishing a message with @at set to a time in the past causes immediate delivery. After a server restart, all past-due schedules also execute immediately. Use Nats-Schedule-TTL so consumers can detect and discard stale deliveries.
Cannot disable once enabled — Setting AllowMsgSchedules to true on a stream is permanent. Plan your stream topology before enabling it.
No source or mirror streams — The scheduling feature cannot be enabled on streams configured as sources or mirrors.
Version split — Single delayed messages (@at) are available in NATS 2.12. Cron expressions, @every intervals, subject sampling, and timezone support require NATS 2.14.
DST risks with cron schedules — Daylight saving time transitions can cause cron jobs to be skipped (spring-forward) or duplicated (fall-back). Sticking with UTC — the default for Nats-Schedule-Time-Zone — avoids this entirely.
Target and schedule subjects must differ — The Nats-Schedule-Target must point to a different subject than the one the schedule message is published to.
AllowMsgTTL is a separate flag — If you want to use Nats-Schedule-TTL, the stream must also have AllowMsgTTL set to true. It is not automatically enabled by AllowMsgSchedules.
The JetStream Message Scheduler turns delayed delivery into a first-class operation. Publish a message with the right headers, and the server handles timing, delivery, and cleanup. Single delays for one-off future events, recurring cron schedules for periodic workflows, and subject sampling for downsampling high-frequency data — all without external schedulers or consumer workarounds.
For the full specification, see ADR-51 in the NATS Architecture and Design repository.



News and content from across the community