Time-To-Live (TTL) is a mechanism that allows key-value pairs to automatically expire after a specified duration. When a key’s TTL elapses, the entry becomes logically invisible to reads and is eventually reclaimed by background cleanup processes. This is a foundational feature in virtually every production key-value store, from in-memory caches like Redis to LSM-tree-based engines used in production distributed databases.
TTL matters because data has a natural lifecycle. Cache entries grow stale, user sessions expire, rate-limiting windows reset, and temporary computation results lose relevance. Without automatic expiration, applications must implement their own cleanup logic, which is error-prone, increases code complexity, and often leads to unbounded storage growth. TTL pushes this responsibility into the storage engine where it can be handled efficiently and consistently.
In a typical production storage layer, TTL is used extensively for managing transient data such as cached query results, intermediate indexing state, and distributed coordination tokens. The implementation integrates TTL awareness into both the read path (lazy expiration checks) and the write path (compaction-time cleanup), ensuring that expired data is filtered out without requiring explicit delete operations from the application layer.
Every key with a TTL has an associated expiration timestamp, typically stored as a Unix epoch value in nanoseconds or seconds. When a key is written with a TTL of, say, 300 seconds, the store computes expiresAt = now() + ttl and persists this alongside the key-value pair. On every subsequent read, the store compares the current time against the expiration timestamp to determine whether the key is still valid.
There are two fundamental strategies for cleaning up expired keys. Lazy deletion checks the TTL only when a key is accessed — if it has expired, the read returns “not found” and the entry is marked for cleanup. Eager deletion uses background processes that proactively scan for and remove expired keys. Most production systems use a combination of both: lazy checks ensure correctness on the read path, while background goroutines prevent expired data from accumulating and wasting storage.
TTL can be specified at different levels of precision. Second-level granularity is the most common (as in Redis), but some systems support millisecond or even nanosecond resolution. The choice of granularity affects both the storage overhead per key (larger timestamp fields) and the precision of expiration. For most use cases, second-level granularity is sufficient, but high-frequency rate limiting or real-time systems may require finer precision.
Per-key TTL allows each individual entry to have its own expiration time, offering maximum flexibility. Per-bucket (or per-column-family) TTL applies a uniform expiration policy to all keys within a logical grouping. Per-bucket TTL is significantly cheaper to implement and enforce — entire SSTable files can be dropped when all their keys share the same TTL window — but it sacrifices the ability to set different lifetimes for individual entries.
In LSM-tree-based stores, the TTL is encoded directly into the value metadata for each entry. A typical on-disk entry format looks like: [key_len][key][value_len][value][expires_at][flags]. The expires_at field is a fixed-width 8-byte integer representing the Unix timestamp at which the key should be considered expired. An expires_at value of zero conventionally means the key has no TTL and never expires.
In an LSM-tree-based storage layer, each key-value entry in the write-ahead log (WAL) and in SSTables carries this expiration metadata. This ensures TTL information survives restarts and is available during compaction without requiring a separate index.
A dedicated background goroutine periodically scans for expired keys and issues internal delete markers (tombstones) for them. This process typically maintains an expiry heap or time-bucketed index that tracks which keys are due to expire next. The goroutine wakes up at a configurable interval (e.g., every 1–5 seconds), pops entries from the heap whose expiration time has passed, and writes tombstones to the active memtable.
In practice, the expiration goroutine runs as part of the storage engine’s lifecycle and is coordinated with the flush and compaction schedulers. It avoids overwhelming the write path by batching tombstone writes and rate-limiting the number of deletions per cycle.
Compaction is where the bulk of expired data is physically reclaimed. During compaction, the merge iterator encounters each key-value pair and checks its expires_at field against the current time. If the key has expired, the compaction process simply drops it from the output SSTable rather than copying it forward. This is effectively a “compaction filter” that runs inline with the standard merge logic.
This integration is powerful because it requires no additional I/O — expired keys are cleaned up as a natural byproduct of the compaction process that was going to happen anyway. For per-bucket TTL, entire SSTable files can be dropped without reading them at all if the file’s metadata indicates that all keys within it have expired.
When a key expires, a tombstone must propagate through all levels of the LSM tree to ensure that older versions of the key in deeper levels are not incorrectly surfaced. The tombstone can only be safely removed during compaction at the bottommost level, once there are no older versions of the key that could be revealed. Premature tombstone removal is a correctness bug that can cause expired keys to reappear — sometimes called “ghost reads.”
TTL is the most natural mechanism for cache invalidation. Rather than tracking dependencies between cached values and their sources, you assign each cache entry a TTL that reflects how long the data is acceptable to serve. When the TTL expires, subsequent reads miss the cache and fetch fresh data from the source. This pattern trades a small amount of staleness for dramatic simplicity.
User sessions in web applications are a classic TTL use case. A session token is stored with a TTL matching the session timeout (e.g., 30 minutes). Each time the user makes a request, the TTL can be refreshed by re-writing the key with a new expiration. If the user is inactive beyond the timeout, the session key automatically expires, and the user is required to re-authenticate.
Sliding-window and fixed-window rate limiters use TTL to automatically clean up counters. For a fixed-window rate limiter, a counter key is created with a TTL equal to the window duration (e.g., 60 seconds). The counter increments with each request, and when the window expires, the key disappears, resetting the count. This avoids the need for explicit cleanup or clock-synchronized reset logic.
Search engines and analytics systems often store intermediate or paginated results with a TTL. When a user initiates a search, the full result set is computed and stored with a short TTL (e.g., 5 minutes). Subsequent pagination requests read from this cached result set. Once the TTL expires, the stale results are cleaned up automatically, freeing storage for new queries.
An expiry heap is a min-heap ordered by expiration timestamp. When a key is written with a TTL, its expiration time is pushed onto the heap. The background cleanup goroutine peeks at the top of the heap and processes all entries whose expiration time is less than or equal to the current time. This gives O(log n) insertion and O(1) peek for the next expiration.
Instead of tracking individual key expirations, keys are grouped into time buckets (e.g., one bucket per second or per minute). The background goroutine iterates over buckets whose time has passed and deletes all keys in each bucket. This approach trades precision for throughput — it processes expirations in bulk and avoids the overhead of maintaining a per-key heap.
The simplest TTL enforcement strategy: every Get() operation checks the expires_at field of the returned value. If the current time exceeds the expiration, the read returns “key not found” and optionally triggers an asynchronous delete. This is cheap and correct but relies on keys being read to trigger cleanup — unread expired keys can linger indefinitely without a complementary eager strategy.
A compaction filter is a callback invoked during LSM compaction for each key-value pair. The filter inspects the expires_at metadata and returns a decision: keep, remove, or transform. For TTL, the filter simply checks whether the key has expired and returns “remove” if so. This is the most storage-efficient cleanup mechanism because it piggybacks on compaction I/O that would occur regardless.
The following example demonstrates a basic in-memory key-value store with per-key TTL support, including expiration timestamp storage and lazy expiration checking on reads.
package ttlstore
import (
"sync"
"time"
)
// Entry holds a value alongside its expiration timestamp.
type Entry struct {
Value []byte
ExpiresAt time.Time // zero value means no expiration
}
// IsExpired reports whether the entry has passed its TTL.
func (e *Entry) IsExpired() bool {
if e.ExpiresAt.IsZero() {
return false
}
return time.Now().After(e.ExpiresAt)
}
// Store is a thread-safe key-value store with TTL support.
type Store struct {
mu sync.RWMutex
entries map[string]*Entry
}
// NewStore creates a new TTL-aware key-value store.
func NewStore() *Store {
return &Store{
entries: make(map[string]*Entry),
}
}
// Put inserts a key with an optional TTL. Pass 0 for no expiration.
func (s *Store) Put(key string, value []byte, ttl time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
entry := &Entry{Value: value}
if ttl > 0 {
entry.ExpiresAt = time.Now().Add(ttl)
}
s.entries[key] = entry
}
// Get retrieves a key, returning nil if not found or expired.
// This implements lazy expiration: expired keys are deleted on access.
func (s *Store) Get(key string) ([]byte, bool) {
s.mu.Lock()
defer s.mu.Unlock()
entry, exists := s.entries[key]
if !exists {
return nil, false
}
// Lazy expiration check
if entry.IsExpired() {
delete(s.entries, key)
return nil, false
}
return entry.Value, true
}
// Delete removes a key from the store.
func (s *Store) Delete(key string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.entries, key)
}
// Len returns the count of non-expired entries.
func (s *Store) Len() int {
s.mu.RLock()
defer s.mu.RUnlock()
count := 0
for _, entry := range s.entries {
if !entry.IsExpired() {
count++
}
}
return count
}
This example adds a background goroutine that eagerly cleans up expired keys on a configurable interval, preventing unbounded growth from keys that are never read after expiration.
package ttlstore
import (
"context"
"log"
"sync"
"time"
)
// TTLStore extends Store with background expiration cleanup.
type TTLStore struct {
mu sync.RWMutex
entries map[string]*Entry
// cleanupInterval controls how often the background goroutine
// scans for expired keys.
cleanupInterval time.Duration
// metrics
expiredCount int64
}
// NewTTLStore creates a store and starts the background cleanup.
func NewTTLStore(ctx context.Context, interval time.Duration) *TTLStore {
ts := &TTLStore{
entries: make(map[string]*Entry),
cleanupInterval: interval,
}
go ts.startCleanupLoop(ctx)
return ts
}
// Put inserts a key-value pair with a TTL.
func (ts *TTLStore) Put(key string, value []byte, ttl time.Duration) {
ts.mu.Lock()
defer ts.mu.Unlock()
entry := &Entry{Value: value}
if ttl > 0 {
entry.ExpiresAt = time.Now().Add(ttl)
}
ts.entries[key] = entry
}
// Get retrieves a value with lazy expiration checking.
func (ts *TTLStore) Get(key string) ([]byte, bool) {
ts.mu.Lock()
defer ts.mu.Unlock()
entry, exists := ts.entries[key]
if !exists {
return nil, false
}
if entry.IsExpired() {
delete(ts.entries, key)
ts.expiredCount++
return nil, false
}
return entry.Value, true
}
// startCleanupLoop runs the background expiration sweep.
func (ts *TTLStore) startCleanupLoop(ctx context.Context) {
ticker := time.NewTicker(ts.cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("ttlstore: cleanup loop stopped")
return
case <-ticker.C:
ts.sweep()
}
}
}
// sweep scans all entries and removes those that have expired.
// In production, this would use a time-bucketed index to avoid
// a full scan. Here we demonstrate the basic pattern.
func (ts *TTLStore) sweep() {
ts.mu.Lock()
defer ts.mu.Unlock()
now := time.Now()
swept := 0
for key, entry := range ts.entries {
if !entry.ExpiresAt.IsZero() && now.After(entry.ExpiresAt) {
delete(ts.entries, key)
swept++
}
}
if swept > 0 {
ts.expiredCount += int64(swept)
log.Printf("ttlstore: swept %d expired keys (%d total)",
swept, ts.expiredCount)
}
}
// ExpiredCount returns the total number of keys expired so far.
func (ts *TTLStore) ExpiredCount() int64 {
ts.mu.RLock()
defer ts.mu.RUnlock()
return ts.expiredCount
}
Neither lazy nor eager expiration alone is sufficient for production use. Lazy checks on the read path guarantee that no expired data is ever returned to a caller, while background eager cleanup prevents storage bloat from unread expired keys. Use both together for correctness and efficiency.
In LSM-tree-based stores, compaction is the most cost-effective place to reclaim expired data. By integrating TTL checks into the compaction filter, expired keys are dropped without any additional I/O. For workloads with uniform TTLs, per-bucket TTL can eliminate entire SSTable files at once, avoiding read amplification entirely.
TTL correctness depends on time. In distributed systems, clock skew can cause inconsistent expiration behavior across nodes. In concurrent environments, TTL refresh operations must be atomic to avoid race conditions. Always account for these realities when building TTL into your storage layer.