# Per-Entry Expiration in Moka: Custom TTL Policies for Individual Cache Entries

> Implement per-entry expiration in Moka with custom TTL policies per key-value pair. Discover Moka's Expiry trait for flexible cache management.

- Repository: [moka-rs/moka](https://github.com/moka-rs/moka)
- Tags: deep-dive
- Published: 2026-03-07

---

**Moka supports per-entry expiration through the `Expiry` trait, allowing custom TTL logic per key-value pair that combines with global cache policies.**

The `moka` crate provides a high-performance concurrent cache for Rust with flexible expiration policies. While global TTL (time-to-live) and TTI (time-to-idle) settings apply uniformly to all entries, Moka also supports **per-entry expiration** through user-defined policies that can vary per key-value pair. This article examines the implementation details in the moka-rs/moka repository, covering the `Expiry` trait interface, builder integration, and runtime behavior.

## Understanding the `Expiry` Trait in [`src/policy.rs`](https://github.com/moka-rs/moka/blob/main/src/policy.rs)

The foundation of per-entry expiration lies in the `Expiry` trait defined in [`src/policy.rs`](https://github.com/moka-rs/moka/blob/main/src/policy.rs). This trait specifies three lifecycle callbacks that the cache invokes when an entry is created, read, or updated.

### Trait Interface and Callback Methods

The trait definition at lines 154-188 exposes three methods:

```rust
pub trait Expiry<K, V> {
    fn expire_after_create(
        &self,
        key: &K,
        value: &V,
        created_at: Instant,
    ) -> Option<Duration> { None }

    fn expire_after_read(
        &self,
        key: &K,
        value: &V,
        read_at: Instant,
        duration_until_expiry: Option<Duration>,
        last_modified_at: Instant,
    ) -> Option<Duration> { duration_until_expiry }

    fn expire_after_update(
        &self,
        key: &K,
        value: &V,
        updated_at: Instant,
        duration_until_expiry: Option<Duration>,
    ) -> Option<Duration> { duration_until_expiry }
}

```

Each method returns `Option<Duration>`, where `Some(duration)` sets a custom expiration time and `None` clears the expiration (making the entry immortal). The default implementations provide sensible passthrough behavior: new entries never expire by default, while reads and updates preserve the existing duration.

## Configuring Per-Entry Expiration via `CacheBuilder`

To attach a custom expiry policy, use the `expire_after` method on the cache builder, located in [`src/sync/builder.rs`](https://github.com/moka-rs/moka/blob/main/src/sync/builder.rs) (lines 487-490):

```rust
pub fn expire_after(self, expiry: impl Expiry<K, V> + Send + Sync + 'static) -> Self {
    let mut builder = self;
    builder.expiration_policy.set_expiry(Arc::new(expiry));
    builder
}

```

This method stores the user-provided `Expiry` implementation inside the cache's `ExpirationPolicy` as an `Arc`, enabling shared ownership across cache operations. The `Send + Sync + 'static` bounds ensure thread-safe access across the cache's concurrent internal structures.

## Runtime Execution in [`src/sync/base_cache.rs`](https://github.com/moka-rs/moka/blob/main/src/sync/base_cache.rs)

The cache core invokes the appropriate `Expiry` callbacks during entry lifecycle events. The implementation in [`src/sync/base_cache.rs`](https://github.com/moka-rs/moka/blob/main/src/sync/base_cache.rs) handles three specific scenarios:

### Entry Creation (Lines 654-669)

When an entry is inserted, the cache calls `Self::expire_after_create`, passing the key, value, and creation timestamp. The returned `Option<Duration>` becomes the entry's initial expiration deadline.

### Entry Read and Update (Lines 314-329)

For reads and updates, the cache invokes `Self::expire_after_read_or_update`. These callbacks receive the current `duration_until_expiry` and can either preserve it, modify it, or clear it entirely by returning `None`.

The returned `Option<Duration>` converts to an absolute expiration timestamp stored in the entry's `EntryInfo`. If the timestamp changes, the cache (re)registers the entry with the internal timer wheel; if the result is `None`, the timer wheel entry is removed entirely, preventing automatic expiration.

## Combining Per-Entry and Global Expiration Policies

Per-entry expiration does not override global TTL/TTI settings—it combines with them. According to the trait documentation in [`src/policy.rs`](https://github.com/moka-rs/moka/blob/main/src/policy.rs) (lines 176-184), an entry expires when the **earliest** of these deadlines is reached:

- Global TTL deadline (cache-level time-to-live)
- Global TTI deadline (cache-level time-to-idle)
- Per-entry custom deadline (from the `Expiry` trait)

This behavior ensures that restrictive global policies act as safety bounds while per-entry policies provide granular control.

## Practical Implementation Examples

### Example 1: Enum-Based Expiration Strategy

This pattern from [`src/sync/cache.rs`](https://github.com/moka-rs/moka/blob/main/src/sync/cache.rs) (lines 57-84) embeds expiration metadata within the value and extracts it during the creation callback:

```rust
use moka::{sync::Cache, Expiry};
use std::{time::{Duration, Instant}, sync::Arc};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Expiration {
    Never,
    Short,
    Long,
}
impl Expiration {
    fn as_duration(&self) -> Option<Duration> {
        match self {
            Expiration::Never => None,
            Expiration::Short => Some(Duration::from_secs(5)),
            Expiration::Long => Some(Duration::from_secs(15)),
        }
    }
}

struct MyExpiry;
impl Expiry<u32, (Expiration, String)> for MyExpiry {
    fn expire_after_create(
        &self,
        _key: &u32,
        value: &(Expiration, String),
        _now: Instant,
    ) -> Option<Duration> {
        value.0.as_duration()
    }
}

fn main() {
    let cache = Cache::builder()
        .max_capacity(100)
        .expire_after(MyExpiry)
        .build();

    cache.get_with(0, || (Expiration::Short, "a".into()));
    cache.get_with(1, || (Expiration::Long,  "b".into()));
    cache.get_with(2, || (Expiration::Never, "c".into()));

    std::thread::sleep(Duration::from_secs(6));
    assert!(!cache.contains_key(&0)); // Expired
    assert!(cache.contains_key(&1));  // Remains
    assert!(cache.contains_key(&2));  // Never expires
}

```

### Example 2: Hybrid Global and Per-Entry Configuration

You can layer per-entry logic atop global policies for comprehensive expiration control:

```rust
let cache = Cache::builder()
    .max_capacity(200)
    .time_to_live(Duration::from_secs(30 * 60)) // 30 min global TTL
    .time_to_idle(Duration::from_secs(5 * 60))  // 5 min global TTI
    .expire_after(MyExpiry)                       // Per-entry policy
    .build();

```

In this configuration, an entry is evicted when **any** deadline triggers—whether from the 30-minute global TTL, 5 minutes of inactivity, or the custom per-entry duration calculated by `MyExpiry`.

## Summary

- The `Expiry` trait in [`src/policy.rs`](https://github.com/moka-rs/moka/blob/main/src/policy.rs) defines three callbacks (`expire_after_create`, `expire_after_read`, `expire_after_update`) for implementing custom expiration logic based on entry content or metadata.
- Attach custom policies via `CacheBuilder::expire_after` in [`src/sync/builder.rs`](https://github.com/moka-rs/moka/blob/main/src/sync/builder.rs), which stores the implementation in the cache's `ExpirationPolicy`.
- The cache core in [`src/sync/base_cache.rs`](https://github.com/moka-rs/moka/blob/main/src/sync/base_cache.rs) invokes these callbacks during entry creation, reads, and updates, converting returned `Option<Duration>` values into absolute timestamps for the timer wheel.
- Per-entry deadlines combine with global TTL/TTI settings, with the entry expiring when the earliest of all applicable deadlines is reached.
- Return `None` from any callback to clear expiration (making the entry immortal), or return `Some(Duration)` to establish or extend a custom lifetime.

## Frequently Asked Questions

### How does per-entry expiration interact with Moka's global TTL settings?

Per-entry expiration works alongside global TTL and TTI settings rather than replacing them. According to the implementation in [`src/policy.rs`](https://github.com/moka-rs/moka/blob/main/src/policy.rs), an entry expires when the earliest of three deadlines is reached: the global TTL, the global TTI, or the per-entry custom expiration. This allows per-entry policies to specify shorter lifetimes than the global default while maintaining global upper bounds as safety limits.

### Can I modify an entry's expiration time after it has been created?

Yes. The `expire_after_read` and `expire_after_update` callbacks in the `Expiry` trait allow dynamic adjustment of expiration times during the entry's lifecycle. These methods receive the current `duration_until_expiry` and can return a new `Duration` to extend or shorten the remaining lifetime, or `None` to remove expiration entirely. The cache updates the timer wheel registration whenever these callbacks return a different value.

### What happens if my `Expiry` callback returns `None`?

Returning `None` from any `Expiry` callback clears the expiration for that entry. In `expire_after_create`, this creates an immortal entry that never expires (unless evicted by capacity constraints). In `expire_after_read` or `expire_after_update`, returning `None` removes the existing timer wheel entry, effectively canceling any previously scheduled expiration. This behavior is implemented in [`src/sync/base_cache.rs`](https://github.com/moka-rs/moka/blob/main/src/sync/base_cache.rs) where the cache removes the entry from the expiration wheel when the callback yields `None`.

### Is per-entry expiration supported in both synchronous and asynchronous Moka caches?

Yes. The `Expiry` trait and `expire_after` builder method are available for both the synchronous `Cache` and asynchronous `future::Cache` implementations in the moka crate. The trait definition in [`src/policy.rs`](https://github.com/moka-rs/moka/blob/main/src/policy.rs) is generic over key and value types, and the builder integration in [`src/sync/builder.rs`](https://github.com/moka-rs/moka/blob/main/src/sync/builder.rs) (and its async counterpart) uses the same underlying `ExpirationPolicy` structure, ensuring consistent behavior across both cache variants.