NEW live workshops: Leaving TIBCO or Solace for NATS Adding an enterprise backbone above MQTT Active/active multi-cloud architectures
All posts

A community member asked how TTL renewal works for NATS Key/Value (KV), especially when KV entries are used as distributed lease locks.

Short answer

There is not a single universal “renew TTL” operation for every KV TTL use case.

  • If your KV bucket uses bucket-level TTL/MaxAge, a successful compare-and-set update of the key creates a new revision and effectively refreshes the entry’s age.
  • If you are relying on custom per-entry/per-message TTL behavior, the high-level KV abstraction may not expose a direct renew operation in the way a lease implementation wants. You may need to use lower-level JetStream publish semantics, or choose a simpler lock design that tolerates expiration races.

Avoid using unconditional writes for locks. A lock renewal should normally be conditional on the revision you already own, so you do not overwrite another process’s lock after yours has expired.

How KV TTL works conceptually

NATS KV is built on JetStream. A KV key is represented by messages in a stream, and a key’s current value is the latest revision for that key. Expiration is ultimately stream retention/age behavior, not a separate timer attached to an external database row.

That implementation detail matters for locks:

  • Writing a new value creates a new revision.
  • Watchers see KV operations, not just “key exists” / “key does not exist”. Each event carries an operation type such as PUT, DELETE, or PURGE.
  • An application refresh write and a TTL-driven removal can be distinguished by that operation metadata: a refresh is delivered as a PUT, and an expiration can be delivered as a PURGE/delete. This distinction is not automatic. It requires the bucket (or its underlying stream) to be configured to write delete markers when entries are removed by age. Without that feature, expiration is silent — a running watcher receives no event, and a watcher that starts afterward simply never sees the key.

Bucket-level TTL: refresh with a conditional update

If all entries in the bucket can share the same expiration policy, configure TTL/MaxAge at the bucket level. Then a lease holder can periodically update the key before it expires.

A typical shape is:

  1. Acquire the lock with a create-if-absent operation.
  2. Record the revision returned by the successful create.
  3. Renew by updating the same key with an expected previous revision.
  4. Store the new revision returned by each successful update.
  5. If the conditional update fails, assume you no longer own the lock.

Pseudo-code:

created = kv.create("locks/job-a", owner_id)
revision = created.revision
while still_working:
sleep(renew_interval)
updated = kv.update("locks/job-a", owner_id, expected_revision=revision)
if updated failed:
stop_work_or_reconcile()
break
revision = updated.revision

The important part is the compare-and-set behavior. A plain unconditional put may be unsafe for a lock because it can recreate or overwrite a key after another instance has acquired it.

Choose a renew interval comfortably shorter than the bucket TTL. For example, if the TTL is 30 seconds, renewing every 10 seconds gives more room for scheduling delays, network hiccups, and retry logic. The exact values depend on your workload and failure model.

Custom per-entry TTL: the tradeoff

Some designs want different TTLs per lock entry. In that case, “renew the TTL” may mean “write the same key again with a new per-message TTL.” Depending on the client API and TTL feature being used, the KV API may not provide a direct operation for that.

One possible advanced approach is to work with the underlying JetStream stream semantics directly:

  • Publish the initial lock only if the key subject has no prior sequence, similar to create-if-absent.
  • On renewal, publish the next value only if the subject’s last sequence is the revision you currently own.
  • Include the desired message TTL on each publish. Per-message TTLs require a NATS server version that supports them (NATS server 2.11 and later) and a stream explicitly configured to allow message TTLs.
  • Treat a failed expected-sequence publish as loss of ownership.

This is more powerful, but it is also more coupled to JetStream details and to the exact TTL support available in your server/client combination. It should be tested carefully before being used as a locking primitive.

Watching for expiration

Watchers should not treat every event the same way.

A renewal/update is a write event. Expiration is a removal event. These are only observable as separate events when the bucket or its underlying stream is configured to emit delete markers (also called subject delete markers or limit markers), a feature of recent NATS server versions. With delete markers enabled, a JetStream-level consumer sees an expiration caused by MaxAge as a marker message carrying a Nats-Marker-Reason: MaxAge header, and a KV watcher sees the same removal as a PURGE/delete operation.

If delete markers are not enabled, expiration produces no event at all, and a watcher cannot tell “the lease expired” apart from “the key was never set.” If your handoff logic depends on observing expiration, confirm that delete markers are enabled on the lock bucket.

That distinction lets another process tell the difference between:

  • “the current owner refreshed the lease”; and
  • “the lease expired and can be acquired.”

Even with that distinction, lock handoff code should still use create/update expectations. Watch notifications are useful signals, but the write that acquires or renews the lock is what must enforce ownership.

A practical lease-lock pattern

For many applications, the simplest robust design is:

  • Use a dedicated KV bucket for locks.
  • Set a bucket TTL appropriate for the maximum lease duration without refresh.
  • Acquire with create-if-absent.
  • Renew with compare-and-set update using the last known revision.
  • Do not use unconditional put for renewal.
  • If renewal fails or times out, stop acting as the lock owner.
  • Make the protected work idempotent or otherwise safe to retry.

This design accepts that, under failures or long pauses, another instance may acquire the lock. That is usually a better tradeoff than adding a second lock or a complex renewal protocol, unless the application truly requires stricter coordination.

When to avoid KV for locks

KV can be a good fit for lightweight leases, but be careful if your design requires:

  • strict mutual exclusion under all partitions and pauses;
  • long critical sections that cannot be safely retried;
  • per-lock TTLs with exact renewal semantics through only the high-level KV API;
  • strong fencing guarantees for external systems.

In those cases, consider adding fencing tokens, making the downstream operation compare the lock revision, or using a coordination mechanism specifically designed for your consistency requirements.

Putting it together

For bucket-level NATS KV TTL, a conditional update creates a fresh revision and is the usual way to refresh a lease. For custom per-entry TTLs, the high-level KV API may not provide the renewal semantics you want, so you either need lower-level JetStream usage or a design that tolerates expiration races. In all cases, use compare-and-set style operations for locks and treat renewal failure as loss of ownership.


Want help from the NATS experts? Meet with our architects to get help tailored to your use case and environment.

Get the NATS Newsletter

News and content from across the community


Cancel