Per-message TTL
The Shaping the stream page capped
ORDERS at a 7-day MaxAge, so every message in the stream lives the
same seven days, then is removed. That's one lifespan for the whole
stream. Sometimes one message needs a different lifespan.
When one message should expire sooner
Consider an orders.cancelled message that only matters for an hour. A
service that reads from the stream has 60 minutes to react to a
cancellation. After that the message is no longer useful, and you don't
want it in the stream for the full seven days.
MaxAge applies one deadline to every message in the stream, so it
can't expire this one ahead of the rest.
A per-message TTL handles this case. TTL is short for time-to-live.
It's a lifespan attached to one message, telling the server to delete
that message after a stated time even when the stream's MaxAge would
keep it longer.
You set it with a header named Nats-TTL when you publish. The value is
a length of time: 1h, 5s, 30m. The server deletes the message that
long after it was stored in the stream.
The stream must opt in
Per-message TTL is off by default. A stream rejects a Nats-TTL header
until you turn the feature on.
The switch is a stream setting named AllowMsgTTL. Turn it on for
ORDERS now:
nats stream edit ORDERS --allow-msg-ttl
Confirm it landed:
nats stream info ORDERS
The settings block gains one line:
Allows Per-Message TTL: true
This switch comes with two catches.
It can't be reversed. You can turn AllowMsgTTL on for an existing
stream, but you can't turn it off again. The server refuses to take it
back off.
It needs server 2.11 or newer. On older servers the setting doesn't exist and the edit has no effect. The rest of this page assumes 2.11+.
Publish a short-lived message
With the feature on, publish an orders.cancelled message that expires
in 60 seconds. The TTL travels along as the Nats-TTL header:
- CLI
- JavaScript/TypeScript
- Go
- Python
- Java
- Rust
- C#/.NET
#!/bin/bash
# Publish an orders.cancelled message that expires 60 seconds after it
# is stored. The per-message TTL rides along as the Nats-TTL header.
# The stream must already have AllowMsgTTL enabled (nats stream edit
# ORDERS --allow-msg-ttl) or this publish is rejected.
# -J makes it a JetStream publish, so the server returns a PubAck
# (Stored in Stream ... Sequence ...) instead of a fire-and-forget send.
nats pub orders.cancelled -J \
--header "Nats-TTL:60s" \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}'
// Publish one order with a per-message TTL. The `ttl` option sets the
// `Nats-TTL` header ("60s"), so the server deletes this message 60 seconds
// after it is stored, even if the stream would otherwise keep it forever.
const pa = await js.publish("orders.cancelled", "order ord_8w2k cancelled", {
ttl: "60s",
});
console.log(`Stored in ${pa.stream} at sequence ${pa.seq}`);
console.log("This message is deleted 60s after it was stored");
// Publish with a 60-second TTL. WithMsgTTL sets the Nats-TTL header, so the
// server deletes this single message 60s after it is stored, ahead of the
// stream's MaxAge.
ack, err := js.Publish(ctx, "orders.cancelled", []byte("order 42 cancelled"),
jetstream.WithMsgTTL(60*time.Second))
if err != nil {
panic(err)
}
fmt.Printf("Stored in %s at sequence %d\n", ack.Stream, ack.Sequence)
# Publish with a Nats-TTL header so the server deletes this message 60
# seconds after it's stored, no matter what the stream would otherwise keep.
ack = await js.publish(
"orders.cancelled",
b'{"id": "A-1001", "reason": "customer"}',
headers={"Nats-TTL": "60s"},
)
print(f"stored in {ack.stream} at sequence {ack.seq}")
// Attach a per-message TTL with the Nats-TTL header. The server keeps
// this message for 60 seconds after it is stored, then deletes it.
Headers headers = new Headers();
headers.add("Nats-TTL", "60s");
PublishAck ack = js.publish("orders.cancelled", headers,
"order 4242 cancelled".getBytes(StandardCharsets.UTF_8));
System.out.println("Stored in stream: " + ack.getStream());
System.out.println("At sequence: " + ack.getSeqno());
// Publish a cancellation that carries its own time-to-live. The `Nats-TTL`
// header tells the server to delete this one message 60 seconds after it is
// stored, even if the rest of the stream never expires. "60s" is a duration
// string; the server also accepts an integer number of seconds (minimum 1s).
// (A typed shortcut also exists: `PublishMessage::build().ttl(Duration)`.)
let mut headers = async_nats::HeaderMap::new();
headers.insert("Nats-TTL", "60s");
let ack = js
.publish_with_headers("orders.cancelled", headers, "order 4242 cancelled".into())
.await?
.await?;
println!(
"Stored on stream {} at sequence {}",
ack.stream, ack.sequence
);
// The message is live now and is removed 60 seconds after it was stored.
// Give one message its own lifetime with the `Nats-TTL` header. The
// server deletes this message 60 seconds after it's stored, while the
// rest of the stream keeps its normal retention.
var headers = new NatsHeaders { ["Nats-TTL"] = "60s" };
var ack = await js.PublishAsync(
subject: "orders.cancelled",
data: """{"order_id":"ord_8w2k","reason":"customer_request"}""",
headers: headers);
output.WriteLine($"Stored in {ack.Stream} at sequence {ack.Seq}");
The publish returns a normal PubAck. The message is stored in the
stream like any other, with a sequence number. The one difference is
that the server now holds a deletion time for it: stored time plus 60
seconds.
Right after publishing, nats stream info ORDERS counts the message.
Wait past the minute, ask again, and the count drops back. The message
expired on its own deadline, while every other message in ORDERS
keeps its full 7-day life.
How TTL and MaxAge interact
A message lives until the first deadline that arrives. The per-message
TTL and the stream MaxAge are both deadlines, and the earlier one
applies.
For the cancellation above, the 60-second TTL arrives long before the
7-day MaxAge. The TTL applies, and the message expires early.
A per-message TTL only ever makes a message expire sooner than
MaxAge would, never later, with one exception covered next.
How to make a message permanent
Sometimes a single message has to outlive everything around it: a
schema definition the rest of the stream depends on, or a baseline
snapshot you replay new consumers against. You want that one message
held past the stream's MaxAge, not just held a little longer.
The exact value never does this. A message published with
Nats-TTL: never never expires, not even at the stream's MaxAge. It
stays until something deletes it on purpose.
Storage versus delivery
A per-message TTL decides how long a message stays stored in the stream. It says nothing about whether a consumer has read the message.
This is the same split between storage and delivery from the publishing page, seen from the other end. A short TTL puts a deadline on the stored copy. If no consumer reads and acks the message before that deadline, the message expires unread. The server deletes it on schedule either way.
If a consumer needs to learn that a message expired rather than just
find it gone, the stream's SubjectDeleteMarkerTTL setting leaves a
delete marker in its place; the
reference covers it.
Size the TTL to the work. A 60-second TTL on a cancellation only makes sense if the consumer that cares about cancellations reads within that minute. Set the TTL shorter than the time in which the message still matters, but long enough for a healthy consumer to keep up.
Two ways a TTL publish can fail
The TTL has to be at least one second. The server rejects a sub-second
or zero Nats-TTL with an invalid-TTL error (err_code 10165) and
stores nothing. The message never lands.
A valid Nats-TTL on a stream that hasn't opted in fails differently.
The server rejects it with a TTL-disabled error (err_code 10166).
The result is the same: no message stored. But the cause differs, so
the two error codes are worth telling apart when you read a failed
PubAck.
In both cases the server never quietly drops the header. A TTL publish either honors the TTL or returns an error.
Pitfalls
A few ways per-message TTL goes wrong in practice.
A TTL header on a stream that never opted in fails the publish. It
does not store the message without a TTL instead. The server rejects
the Nats-TTL header with a per-message TTL is disabled error
(err_code 10166), and nothing is stored. The danger is
assuming the message landed with its TTL when it never landed at all.
Do check the Allows Per-Message TTL line in nats stream info ORDERS
before you rely on the header. Don't publish a TTL message and walk away
on a stream you haven't confirmed opted in.
- CLI
- JavaScript/TypeScript
- Go
- Python
- Java
- Rust
- C#/.NET
#!/bin/bash
# A Nats-TTL header on a stream that hasn't opted in is rejected. The
# JetStream publish fails loudly (err_code 10166) and stores nothing —
# the message is not quietly kept forever without a TTL.
# A stream that never enabled AllowMsgTTL. Its config has no
# "Allows Per-Message TTL" line (the line only appears once it's on).
nats stream add ORDERS_NO_TTL --subjects "no-ttl.>" --defaults
# A JetStream publish (-J) with a TTL header is rejected: nats pub exits
# non-zero and prints the error, and nothing is stored. A plain core
# publish would hide this — it returns no PubAck, so you'd never see the
# rejection.
nats pub no-ttl.msg -J \
--header "Nats-TTL:60s" \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}'
# -> nats: error: per-message TTL is disabled (10166)
# The fix is to opt the stream in once (a one-way switch), then republish.
nats stream edit ORDERS_NO_TTL --allow-msg-ttl
// Publish with a TTL to a stream that has per-message TTLs disabled. The server
// rejects the message with err 10166 ("per-message TTL is disabled") and stores
// nothing. Enabling `allow_msg_ttl` on the stream is the fix.
try {
await js.publish("no-ttl.msg", "order ord_8w2k cancelled", { ttl: "60s" });
} catch (err) {
if (err instanceof JetStreamApiError) {
console.log(`Rejected (err ${err.code}): ${err.message}`);
console.log("Nothing was stored");
} else {
throw err;
}
}
// Publishing with a TTL to a stream that hasn't enabled AllowMsgTTL fails.
// The server returns "per-message TTL is disabled" (err_code 10166) and
// stores nothing. The fix is to opt the stream in with AllowMsgTTL: true.
_, err = js.Publish(ctx, "no-ttl.msg", []byte("order 42 cancelled"),
jetstream.WithMsgTTL(60*time.Second))
if err != nil {
fmt.Printf("Publish rejected, message not stored: %s\n", err)
return
}
# Publishing with a Nats-TTL header fails when the stream doesn't allow
# per-message TTLs. The server rejects it with err_code 10166 and stores
# nothing.
try:
await js.publish(
"no-ttl.msg",
b'{"id": "A-1002"}',
headers={"Nats-TTL": "60s"},
)
except APIError as e:
# "per-message TTL is disabled" — the fix is to create or update the
# stream with allow_msg_ttl=True.
print(f"publish rejected: {e.description} (err_code {e.err_code})")
// Publishing a Nats-TTL header to a stream without AllowMsgTTL is
// rejected by the server, and nothing is stored.
Headers headers = new Headers();
headers.add("Nats-TTL", "60s");
try {
js.publish("no-ttl.cancelled", headers,
"order 4242 cancelled".getBytes(StandardCharsets.UTF_8));
}
catch (JetStreamApiException e) {
// Error 10166: "per-message TTL is disabled". Enabling
// AllowMsgTTL on the stream is the fix.
System.out.println("Publish rejected [" + e.getApiErrorCode() + "]: "
+ e.getErrorDescription());
}
// Try to publish a message carrying a `Nats-TTL` header to a stream that does
// not allow per-message TTLs. The server refuses the write with error 10166,
// "per-message TTL is disabled", so nothing is ever stored.
let mut headers = async_nats::HeaderMap::new();
headers.insert("Nats-TTL", "60s");
match js
.publish_with_headers("no-ttl.event", headers, "should be rejected".into())
.await?
.await
{
Ok(ack) => println!("Unexpected: message stored at sequence {}", ack.sequence),
Err(err) => {
println!("Publish rejected, nothing stored: {err}");
// The fix is to enable TTLs on the stream: create (or update) it with
// `allow_message_ttl: true`, as in the publish-with-TTL example.
}
}
// The `Nats-TTL` header only works on a stream created with
// AllowMsgTTL = true. Publishing it to a stream that hasn't enabled the
// feature is rejected instead of silently storing the message forever.
var headers = new NatsHeaders { ["Nats-TTL"] = "60s" };
try
{
await js.PublishAsync(
subject: "no-ttl.cancelled",
data: """{"order_id":"ord_8w2k","reason":"customer_request"}""",
headers: headers);
}
catch (NatsJSApiException ex)
{
// 10166: per-message TTL is disabled. The fix is to recreate the
// stream with AllowMsgTTL = true so it accepts the `Nats-TTL` header.
rejection = ex;
output.WriteLine($"Rejected ({ex.Error.Code}): {ex.Error.Description}");
}
A short TTL deletes the message whether or not a consumer read it. The TTL is a deadline on the stored copy, not a guarantee about delivery. It counts down regardless of what any consumer is doing. If the TTL runs out while the consumer that handles cancellations is down, backed up, or reading slowly mid-batch, the server deletes the message and nobody processes the cancellation. A consumer that already holds a message doesn't pause that message's TTL. Do size the TTL to outlast the lag of your slowest healthy consumer. Don't set a TTL shorter than the time in which the message still has to be acted on.
Turning AllowMsgTTL on can't be undone. You can turn the feature
on with nats stream edit ORDERS --allow-msg-ttl, but the server
refuses to turn it back off. Do turn it on on purpose, knowing the
stream keeps the feature for life. Don't flip it on to test something
and expect to undo it.
Where you are
ORDERS now has AllowMsgTTL turned on, a switch you can't turn back
off. You published an orders.cancelled message with a 60-second
Nats-TTL and saw it expire while the rest of the stream remained. The
earlier of TTL and MaxAge always applies.
The stream still holds its earlier messages under the 7-day MaxAge
set on the Shaping the stream page.
Nothing else changed.
What's next
That's the last of the stream and consumer mechanics. Where to go next recaps the model you built and points to the chapters that take it further.
See also
- Reference → Per-Message TTL
— the
Nats-TTLheader,SubjectDeleteMarkerTTL, and delete markers in full. - Reference → Stream Configuration
—
AllowMsgTTLalongside every other stream field.