Back to blog

Cache Stampede Prevention: Probabilistic Early Expiration (X-Fetch)

|
| caching, redis, performance, algorithms, distributed-systems, stampede

The night the cache expired, my phone did not. “Why did database CPU spike to 100% at exactly 3:00 AM?” That’s when your cache expired. All 500 concurrent users hit the DB simultaneously. This is cache stampede (thundering herd).

Tested on: Redis 7.2, Go 1.21, 10,000 concurrent clients

The Stampede Problem

What Happens

Normal operation (cache hit):
  Request 1 → Cache HIT → Return cached data (1ms)
  Request 2 → Cache HIT → Return cached data (1ms)
  Request 3 → Cache HIT → Return cached data (1ms)

At expiration (cache miss):
  Request 1 → Cache MISS → Query DB (500ms) → Store cache
  Request 2 → Cache MISS → Query DB (500ms) → Store cache (duplicate!)
  Request 3 → Cache MISS → Query DB (500ms) → Store cache (duplicate!)
  ...
  Request 500 → Cache MISS → Query DB → 💥 DB overload

All 500 requests hit DB before first one finishes storing

Impact at Scale

Scenario: Product listing cache with 60s TTL
- 10,000 RPS to endpoint
- Cache expires at 12:00:00
- DB query takes 200ms

Without protection:
  12:00:00.000 - Cache expires
  12:00:00.000 - 12:00:00.200 - 2000 requests hit DB
  12:00:00.200 - First response returns, cache set
  12:00:00.200+ - Remaining requests still hitting DB

Result: 2000 concurrent DB queries instead of 1

Common Solutions (and Their Problems)

1. Distributed Lock

// Lock-based approach
func GetWithLock(key string) (string, error) {
    value, err := redis.Get(key)
    if err == nil {
        return value, nil
    }

    // Try to acquire lock
    lockKey := "lock:" + key
    acquired, _ := redis.SetNX(lockKey, "1", 10*time.Second)

    if acquired {
        defer redis.Del(lockKey)
        value = expensiveQuery()
        redis.Set(key, value, 60*time.Second)
        return value, nil
    }

    // Wait for lock holder to populate cache
    time.Sleep(100 * time.Millisecond)
    return GetWithLock(key) // Retry
}

// Problems:
// 1. Lock contention adds latency
// 2. Lock holder failure = everyone waits
// 3. Sleep/retry wastes resources
// 4. Complex failure modes

2. Background Refresh

// Background refresh with stale data
func GetWithBackground(key string) (string, error) {
    value, ttl, _ := redis.GetWithTTL(key)

    if ttl > 0 && ttl < 10*time.Second {
        // Almost expired, trigger background refresh
        go refreshCache(key)
    }

    return value, nil  // Return stale data
}

// Problems:
// 1. Returns stale data during refresh window
// 2. Needs separate goroutine management
// 3. Multiple goroutines can trigger simultaneously

X-Fetch Algorithm

The Concept

Probabilistic Early Expiration:
- Each request has a CHANCE to refresh cache BEFORE expiration
- Probability increases as TTL decreases
- First request to "win" refreshes, others get cached data

Formula:
  shouldRefetch = random() < β × log(currentTime - fetchTime)

Where:
  β = aggressiveness parameter (typically 1.0)
  Higher β = earlier refresh
  Lower β = closer to expiration before refresh

Implementation

// xfetch.go
package cache

import (
    "context"
    "encoding/json"
    "math"
    "math/rand"
    "time"

    "github.com/redis/go-redis/v9"
)

type XFetchCache struct {
    client *redis.Client
    beta   float64  // Aggressiveness parameter
}

type CachedValue struct {
    Data      json.RawMessage `json:"data"`
    Delta     float64         `json:"delta"`     // Time to compute value
    FetchedAt int64           `json:"fetchedAt"` // Unix timestamp
    TTL       int64           `json:"ttl"`       // Original TTL in seconds
}

func NewXFetchCache(client *redis.Client, beta float64) *XFetchCache {
    return &XFetchCache{
        client: client,
        beta:   beta,
    }
}

func (c *XFetchCache) Get(
    ctx context.Context,
    key string,
    ttl time.Duration,
    fetch func() (any, error),
) (json.RawMessage, error) {

    // Try to get from cache
    cached, err := c.client.Get(ctx, key).Bytes()
    if err == nil {
        var cv CachedValue
        if json.Unmarshal(cached, &cv) == nil {
            // Check if we should probabilistically refresh
            if !c.shouldRefetch(cv) {
                return cv.Data, nil
            }
        }
    }

    // Cache miss or probabilistic refresh triggered
    return c.fetchAndStore(ctx, key, ttl, fetch)
}

func (c *XFetchCache) shouldRefetch(cv CachedValue) bool {
    now := time.Now().Unix()
    age := float64(now - cv.FetchedAt)
    remaining := float64(cv.TTL) - age

    if remaining <= 0 {
        return true // Already expired
    }

    // X-Fetch formula: probability increases as expiry approaches
    // P(refetch) = β × δ × log(random) + remaining < 0
    // Where δ = computation time (delta)

    threshold := remaining - c.beta*cv.Delta*math.Log(rand.Float64())

    return threshold <= 0
}

func (c *XFetchCache) fetchAndStore(
    ctx context.Context,
    key string,
    ttl time.Duration,
    fetch func() (any, error),
) (json.RawMessage, error) {

    start := time.Now()
    data, err := fetch()
    if err != nil {
        return nil, err
    }
    delta := time.Since(start).Seconds()

    dataBytes, _ := json.Marshal(data)

    cv := CachedValue{
        Data:      dataBytes,
        Delta:     delta,
        FetchedAt: time.Now().Unix(),
        TTL:       int64(ttl.Seconds()),
    }

    cvBytes, _ := json.Marshal(cv)

    // Store with extra buffer for probabilistic refresh window
    storeTTL := ttl + time.Duration(c.beta*delta*10)*time.Second
    c.client.Set(ctx, key, cvBytes, storeTTL)

    return cv.Data, nil
}

Usage Example

// main.go
func main() {
    rdb := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })

    cache := NewXFetchCache(rdb, 1.0) // beta = 1.0

    http.HandleFunc("/products", func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()

        data, err := cache.Get(ctx, "products:featured", 60*time.Second, func() (any, error) {
            // Expensive database query
            return db.GetFeaturedProducts()
        })

        if err != nil {
            http.Error(w, err.Error(), 500)
            return
        }

        w.Header().Set("Content-Type", "application/json")
        w.Write(data)
    })

    http.ListenAndServe(":8080", nil)
}

Benchmark Results

Test Setup

// benchmark_test.go
func BenchmarkCacheStrategies(b *testing.B) {
    // Simulate 10,000 concurrent requests at cache expiry
    clients := 10000
    cacheExpiry := time.Now()

    // Strategy 1: No protection
    // Strategy 2: Distributed lock
    // Strategy 3: X-Fetch (beta=1.0)
}

Results

10,000 concurrent requests at cache expiration:

No Protection:
  DB queries:        10,000
  p50 latency:       2,340ms
  p99 latency:       8,920ms
  DB CPU:            100%
  Errors:            15% (timeouts)

Distributed Lock:
  DB queries:        1
  p50 latency:       580ms  (waiting for lock)
  p99 latency:       1,240ms
  DB CPU:            5%
  Errors:            0%

X-Fetch (beta=1.0, 60s TTL):
  DB queries:        3-5  (probabilistic refresh)
  p50 latency:       12ms  (served from cache)
  p99 latency:       45ms
  DB CPU:            2%
  Errors:            0%

X-Fetch wins: No lock contention, minimal DB load

Tuning Beta Parameter

Beta controls refresh aggressiveness:

beta = 0.5 (conservative):
  - Refresh starts ~5s before expiry
  - Higher chance of stampede if traffic spikes
  - Less "wasted" early refreshes

beta = 1.0 (balanced):
  - Refresh starts ~10-15s before expiry
  - Good balance for most workloads

beta = 2.0 (aggressive):
  - Refresh starts ~30s before expiry
  - Very low stampede risk
  - More frequent refreshes

Recommendation:
  beta = delta × 2
  Where delta = average fetch time in seconds

TypeScript Implementation

// xfetch.ts
import Redis from 'ioredis';

interface CachedValue<T> {
  data: T;
  delta: number;
  fetchedAt: number;
  ttl: number;
}

export class XFetchCache {
  constructor(
    private redis: Redis,
    private beta: number = 1.0
  ) {}

  async get<T>(
    key: string,
    ttlSeconds: number,
    fetch: () => Promise<T>
  ): Promise<T> {
    const cached = await this.redis.get(key);

    if (cached) {
      const cv: CachedValue<T> = JSON.parse(cached);

      if (!this.shouldRefetch(cv)) {
        return cv.data;
      }
    }

    return this.fetchAndStore(key, ttlSeconds, fetch);
  }

  private shouldRefetch<T>(cv: CachedValue<T>): boolean {
    const now = Date.now() / 1000;
    const age = now - cv.fetchedAt;
    const remaining = cv.ttl - age;

    if (remaining <= 0) return true;

    // X-Fetch probability formula
    const threshold = remaining - this.beta * cv.delta * Math.log(Math.random());

    return threshold <= 0;
  }

  private async fetchAndStore<T>(
    key: string,
    ttlSeconds: number,
    fetch: () => Promise<T>
  ): Promise<T> {
    const start = Date.now();
    const data = await fetch();
    const delta = (Date.now() - start) / 1000;

    const cv: CachedValue<T> = {
      data,
      delta,
      fetchedAt: Date.now() / 1000,
      ttl: ttlSeconds,
    };

    // Extra buffer for probabilistic window
    const storeTtl = Math.ceil(ttlSeconds + this.beta * delta * 10);

    await this.redis.setex(key, storeTtl, JSON.stringify(cv));

    return data;
  }
}

// Usage
const cache = new XFetchCache(redis, 1.0);

app.get('/products', async (req, res) => {
  const products = await cache.get('products:featured', 60, async () => {
    return await db.query('SELECT * FROM products WHERE featured = true');
  });

  res.json(products);
});

Monitoring

Metrics to Track

// Add Prometheus metrics
var (
    cacheHits = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "xfetch_cache_hits_total",
        },
        []string{"key_prefix"},
    )

    cacheMisses = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "xfetch_cache_misses_total",
        },
        []string{"key_prefix"},
    )

    earlyRefreshes = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "xfetch_early_refresh_total",
        },
        []string{"key_prefix"},
    )

    fetchDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name: "xfetch_fetch_duration_seconds",
        },
        []string{"key_prefix"},
    )
)

Grafana Dashboard

# Cache hit rate
sum(rate(xfetch_cache_hits_total[5m])) /
(sum(rate(xfetch_cache_hits_total[5m])) + sum(rate(xfetch_cache_misses_total[5m])))

# Early refresh rate (should be low but non-zero)
rate(xfetch_early_refresh_total[5m])

# Fetch duration distribution
histogram_quantile(0.99, rate(xfetch_fetch_duration_seconds_bucket[5m]))

Checklist

## X-Fetch Implementation

### Setup
- [ ] Implement X-Fetch wrapper for cache client
- [ ] Store delta (fetch time) alongside cached data
- [ ] Store fetchedAt timestamp
- [ ] Add buffer TTL beyond nominal expiry

### Tuning
- [ ] Start with beta = 1.0
- [ ] Adjust based on fetch duration
- [ ] Monitor early refresh rate

### Monitoring
- [ ] Track cache hit/miss ratio
- [ ] Track early refresh triggers
- [ ] Alert on hit rate drops

### Testing
- [ ] Load test at cache expiry
- [ ] Verify single/few DB queries during stampede
- [ ] Compare latency with lock-based approach

Conclusion

X-Fetch prevents cache stampedes without locks:

  1. Probabilistic refresh before expiration
  2. No lock contention - requests don’t wait for each other
  3. Self-tuning - adapts to fetch duration
  4. Simple implementation - just wrap your cache calls

Stop using distributed locks for cache refresh. Let probability do the work.


Related posts

Cite this article

If you reference this post, please link to the original URL and credit the author.

Michal Drozd. "Cache Stampede Prevention: Probabilistic Early Expiration (X-Fetch)". https://www.michal-drozd.com/en/blog/cache-stampede-xfetch/ (Published June 14, 2025).