TTL and limits
Every key in INVENTORY so far lives until you overwrite or delete it.
That's the right default for a stock count, but it's not the only thing
a bucket holds. Some values should clean themselves up, such as a
flash-sale price, a short-lived session token, or a "this SKU is locked
for the next 30 minutes" flag.
This page adds two ways to bound the bucket by time. The first is the per-key TTL, a single key that expires on its own. The second is the set of bucket limits that bound the whole thing: total size, value size, history depth. When a key expires, the server leaves a marker so the warehouse dashboard learns the value is gone, the same way it learned about every other change.
You still have the INVENTORY bucket from the previous pages, with
widget-blue decremented to 40 and the warehouse dashboard watching.
You'll add a key with a TTL to it.
A per-key TTL expires a single value
A TTL (time-to-live) is how long a value stays in the bucket before the server removes it. A per-key TTL attaches that clock to one key. The key lives for its TTL, then disappears on its own, with no service having to remember to delete it.
Per-key TTL is set at create time, and only at create time. You hand the TTL to create alongside the value, and the key starts its countdown the moment it lands. This is a deliberate restriction: a TTL belongs to the value you're writing now, not to a value that might be put over it later.
One setup step comes first. Per-key TTLs ride on a bucket feature called
limit markers, the same mechanism that leaves a trace when a value
expires. A bucket needs limit markers enabled before any key in it
can carry a TTL. INVENTORY was created without them, so you turn them on
once, then create the timed key:
- CLI
#!/bin/bash
# Give a single key its own lifetime with a per-key TTL.
#
# Per-key TTL rides on the bucket's Limit Markers, so the bucket must have
# them enabled first. INVENTORY was created without them, so turn them on
# once (the duration is how long the bucket keeps the expiry marker):
nats kv edit INVENTORY --marker-ttl 1h
# Now create flash-sale with a 30-minute TTL. The value expires on its own
# 30 minutes after this create; no service has to remember to clean it up.
# Note: TTL is accepted on CREATE only.
nats kv create INVENTORY flash-sale 99 --ttl 30m
# Expected: the key is written and counts down on its own.
#
# INVENTORY > flash-sale created
#
# Per-key TTL requires nats-server 2.11 or newer. On an older server the
# create is rejected because the bucket cannot enable Limit Markers.
# --- Per-key TTL is create-only: to change it, delete then create ---------
#
# There is no --ttl on put or update. Passing one does nothing; the key
# keeps its original TTL. To give flash-sale a different lifetime, remove
# it and create it again with the new TTL:
nats kv del INVENTORY flash-sale --force
nats kv create INVENTORY flash-sale 99 --ttl 10m
# The key now expires in 10 minutes instead of 30.
The flash-sale key now holds 99 and will remove itself 30 minutes
later. Because the TTL is stored with the value, this happens without a
cron job, a separate cleanup service, or a sweep.
Per-key TTL needs nats-server 2.11 or newer; that's the release that added limit markers. On an older server, enabling markers on the bucket is rejected, and the timed create fails with it. If you're on 2.11, the feature is available.
Bucket limits bound the whole bucket
Where a per-key TTL bounds one value, bucket limits bound the whole bucket. They keep a key-value store from growing without end, and you set them when you create the bucket. Three of them matter most:
- Max bucket size: the total bytes the bucket may hold across every key and every kept revision. The bucket won't grow past it.
- Max value size: the largest a single value may be. A put of something bigger is rejected. Key-value values are meant to be small; large values belong in the Object Store.
- History depth: how many prior revisions each key keeps, which you already met on the previous page. It caps at 64, and it doubles as a per-key cap: it's the most messages any single key may hold.
Here those limits go on a throwaway CACHE bucket, so the numbers
stand on their own and don't imply anything about INVENTORY:
- CLI
#!/bin/bash
# Create a throwaway CACHE bucket to show the three bucket-level limits.
# We use CACHE, not the pinned INVENTORY bucket, on purpose: INVENTORY's
# TTLs are per-key, not bucket-wide, and we do not want to imply the whole
# INVENTORY bucket expires.
#
# --ttl 1h bucket-wide max age: every value older than 1h is
# removed, regardless of key. This is the bucket's
# MaxAge, not a per-key TTL.
# --max-bucket-size the bucket's total size cap, in bytes.
# --max-value-size the largest a single value may be, in bytes.
nats kv add CACHE \
--history 1 \
--ttl 1h \
--max-bucket-size 16MB \
--max-value-size 64KB
# Expected output ends with the configured limits, e.g.
#
# CACHE Key-Value Store
#
# Configuration:
# Bucket Name: CACHE
# History: 1
# TTL: 1h0m0s
# Max Bucket Size: 16 MiB
# Max Value Size: 64 KiB
# ...
#
# A bucket at its size cap discards the OLDEST value to make room for a new
# one (discard 'new' is not used here) — size the bucket for the working
# set, not the average, or live values fall off the back.
The --ttl on the bucket above is a different clock from the per-key TTL.
A bucket TTL expires every value once it reaches that age; the per-key
TTL expires one value. The bucket-wide form is the general per-message
expiry mechanism applied to a whole bucket, and its details live with
message TTL in JetStream. This chapter
teaches the per-key form, which is the one unique to key-value.
The full set of bucket configuration options is documented in Reference → Create Stream, because a bucket is created as a stream and these limits map onto stream fields. Here you only need the three above.
Expiry leaves a marker for watchers
When a per-key TTL fires, the server leaves a marker instead of
silently dropping the value: a small message that records the key is gone and why.
You may have heard the marker called a tombstone elsewhere; the term in
key-value is marker, and a TTL expiry leaves one with the reason
MaxAge: the value aged out.
The marker matters because of the warehouse dashboard. A watcher receives
the marker as a delete on the key, exactly as if someone had removed it by
hand. Without the marker, a watcher that saw flash-sale appear would
never learn it had vanished, and its view of the bucket would drift out of
date. The marker is how live readers stay correct when a value expires on
its own.
The animation walks the timeline: the inventory service creates
flash-sale with a 30-minute TTL; the clock advances past it; the server
places a marker on the key with reason MaxAge; and the warehouse
dashboard receives that marker as a delete. The value expired without
any service modifying it, and the watcher was still notified through the
marker.
Pitfalls
Two mistakes are common the first time you add a TTL or a limit to a bucket. Both come from expecting a TTL or a limit to behave in a way it does not.
A per-key TTL is set at create, and only at create. There's no
--ttl on put or on update; passing one does nothing, and the key keeps
whatever TTL it had. The instinct to "extend the TTL" by writing the
key again doesn't work: a put leaves the original clock running, and an
update resets it to no TTL at all. To give a key a different TTL, you
delete it and create it again with the new TTL. Use delete-then-create
to change a TTL, not put or update.
The handling is in the create snippet above: after the timed create, it
deletes flash-sale and creates it again with a shorter TTL, which is the
only way to change one.
Limits reject, they do not make room for an oversized value. A put of
a value larger than the bucket's max value size is rejected outright, and
so is a put that would push the bucket past its max size; the server
returns an error and leaves every existing value in place. The inventory
service sees that error on the put, not later on a get, so a CACHE
bucket sized for the average load starts refusing writes the moment a
burst pushes it to the cap. Size the bucket for the working set you
actually need to hold, not the average, so a busy minute doesn't bounce
writes you needed to land, and cap max value size above the largest value
you legitimately store.
Where you are
You now have:
- An
INVENTORYbucket with limit markers enabled and aflash-salekey that expires on its own after a per-key TTL. - A feel for the three bucket limits (max bucket size, max value size, and history depth) that bound the whole bucket.
- A working model of the expiry marker: when a value ages out, the server
leaves a marker with reason
MaxAge, and the warehouse dashboard receives it as a delete.
The bucket is now complete. It holds keys with values, keeps history, supports safe concurrent writes, and has values that remove themselves on a TTL.
What's next
The next page shows the internals. It covers the KV_INVENTORY stream
that's been under the bucket the whole time, the direct read path, and the
difference between delete and purge.
Continue to Under the hood.
See also
- Reference → Create Stream — every bucket limit and its valid range.
- JetStream → Message TTL — the per-message expiry mechanism the bucket-wide TTL is built on.
- Object Store — where large values belong when they outgrow a key-value bucket.