Redis Cache Expiration Causes Hidden Latency Spikes

Your API Is Fast… Until It Isn’t

The dashboard looked perfect. Average latency was around 3 ms. Throughput was above 14,000 requests per second. Error rate stayed at 0%.

If you stopped there, you would probably call the API healthy.

But a few users were still seeing slow responses. Not constant slowness. Not an outage. Just random requests that suddenly took 500 ms or more. That is the kind of performance issue that hides in production because the system looks fine from a distance.

The root cause was not the database being permanently slow. It was not Redis being slow either. The spike happened at a very specific moment: when a hot Redis key expired and many concurrent requests tried to rebuild it at the same time.

The Incident

This experiment simulates a pattern I have seen in real backend systems: an endpoint is fast for most requests, then suddenly slow for a small group of requests every time the cache expires.

The dangerous part is that the slow requests are rare compared to the total volume. They barely move the average, but they absolutely affect real users.

SignalWhat it showed
Average latency3 ms
Maximum latency556 ms
Throughput14,462 requests/sec
Error rate0%
User experienceOccasional latency spikes during cache expiration

Initial Assumption

When people hear “Redis cache,” they often assume the cache removes the performance problem. That assumption is incomplete. Redis makes cache hits fast, but expiration creates a transition moment where the application falls back to the slower path.

The first theory was simple: maybe the API was fine because the average latency was low. The second theory was that the database latency was causing the occasional spikes. Both were too shallow. The important question was: what happens exactly when the cached value disappears?

Experiment Overview

The setup was intentionally small so the behavior would be easy to isolate.

  • Spring Boot API endpoint.
  • Redis cache with a 30-second TTL.
  • Simulated database latency between 100 and 300 ms.
  • JMeter load test sending many requests to the same key.
  • One hot cache key to make expiration behavior visible.
GET /api/cache/test/1

All requests targeted the same key on purpose. In production, this is common for hot data: product details, account configuration, feature flags, exchange rates, permission snapshots, reporting metadata, or any shared object that many users request repeatedly.

The Cache Path

The normal path is fast because Redis returns the value immediately. The slow path appears only when Redis misses and the application has to rebuild the value.

public Response getData(Long id) {
    String cacheKey = "cache:test:" + id;

    Response cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return cached;
    }

    Response value = database.loadData(id); // simulated 100-300 ms
    redisTemplate.opsForValue().set(cacheKey, value, Duration.ofSeconds(30));

    return value;
}

There is nothing unusual in this code. This is the standard cache-aside pattern. The problem is not the pattern itself. The problem is what happens when many threads execute the miss path at the same time.

Real Performance Results

The JMeter result looked excellent at first glance.

MetricResult
Total requests1,734,951
Average latency3 ms
Maximum latency556 ms
Throughput14,462 requests/sec
Error rate0%

This is exactly why average latency can be dangerous. A few hundred slow requests can disappear inside more than a million fast cache hits. The average stays low, while the users who hit the expiration window still experience a slow API.

The Hidden Problem

The slow responses appeared when three things happened at the same time:

  • The Redis entry expired.
  • Many concurrent requests missed the cache.
  • Those requests fell back to the database or expensive rebuild logic together.

That moment creates a short burst of latency. The cache is fast before expiration. It is fast again after one request repopulates the value. But during the miss window, the endpoint behaves like the cache does not exist.

cache hit  -> 3 ms
cache hit  -> 4 ms
cache hit  -> 3 ms
cache miss -> 281 ms
cache miss -> 344 ms
cache miss -> 512 ms
cache hit  -> 3 ms
cache hit  -> 4 ms

Why Average Latency Fails

Average latency compresses the whole system into one number. That is useful for a quick summary, but it is a weak signal for bursty performance problems.

Request groupLatency
~99% of requests3-5 ms
~1% of requests200-500+ ms
Average result~3 ms

The average says the service is fast. The maximum says at least one user had a very different experience. P95 and P99 are the metrics that expose this gap.

Visualizing the Behavior

When latency is plotted over time, the pattern is easier to understand. It is not random noise. It forms a repeating wave: stable low latency, sudden spike, recovery, then another spike at the next expiration cycle.

That wave is the important clue. If the spike repeats around the TTL boundary, the cache expiration policy is part of the performance profile.

Root Cause: Cache Stampede

At the moment of expiration, the cache entry is removed. The next request misses Redis and rebuilds the value. If traffic is low, this may be fine. If traffic is high, many requests can miss at nearly the same time.

That is cache stampede. The system does not have one request rebuilding the value. It has many requests doing the same expensive work concurrently.

  • Redis removes the key at the TTL boundary.
  • Concurrent requests miss the same key.
  • Each request calls the database or expensive loader.
  • Database and CPU load rise briefly.
  • Latency spikes until the cache is repopulated.

This is why cache expiration is not passive. It is an active performance event.

Why It Matters in Production

In production, hot keys are rarely evenly distributed. Some keys receive much more traffic than others. When those keys expire, the miss window can create a sudden load burst against downstream systems.

This can show up as API latency, database CPU spikes, connection pool pressure, thread pool saturation, or short cascading failures. The API may still have a 0% error rate, but users feel the delay.

What You Should Measure Instead

For cache-related performance work, average latency is not enough. You need metrics that preserve the shape of the slowdown.

  • P95 and P99 latency for the API endpoint.
  • Maximum latency during load tests.
  • Redis cache hit ratio by key or endpoint.
  • Database query rate during cache expiration windows.
  • Cache evictions, misses, and rebuild duration.
  • Thread pool and connection pool saturation.

The goal is to correlate the API spike with cache misses and database activity. If the spike aligns with the TTL boundary, the cache policy is no longer just an optimization detail. It is part of the system behavior.

How to Prevent Cache Stampede

1. Add TTL Jitter

If every hot key uses the same TTL, many keys can expire together. TTL jitter spreads expiration times so the system avoids synchronized misses.

int baseTtlSeconds = 30;
int jitterSeconds = ThreadLocalRandom.current().nextInt(10);

Duration ttl = Duration.ofSeconds(baseTtlSeconds + jitterSeconds);
redisTemplate.opsForValue().set(cacheKey, value, ttl);

Jitter is simple and cheap. It does not eliminate cache misses, but it reduces the chance that many expensive rebuilds happen at exactly the same time.

2. Use a Distributed Lock

For very hot keys, you may want only one request to rebuild the cache while the others wait, retry, or serve stale data.

String lockKey = "lock:" + cacheKey;
boolean locked = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

if (locked) {
    try {
        Response value = database.loadData(id);
        redisTemplate.opsForValue().set(cacheKey, value, ttl);
        return value;
    } finally {
        redisTemplate.delete(lockKey);
    }
}

return waitAndReadFromCache(cacheKey);

The trade-off is complexity. A lock can protect the database, but you must handle lock timeout, failure paths, and what callers do while the refresh is in progress.

3. Cache Preloading

For known hot keys, refresh the cache before users hit the expiration window. This is useful for predictable data such as configuration, dashboards, top products, tenant metadata, and reporting summaries.

@Scheduled(fixedDelay = 20_000)
public void refreshHotKeys() {
    for (Long id : hotKeyRegistry.ids()) {
        Response value = database.loadData(id);
        redisTemplate.opsForValue().set(cacheKey(id), value, Duration.ofSeconds(40));
    }
}

Preloading moves work out of the request path. The trade-off is that the system now spends background capacity to keep data warm.

4. Stale-While-Revalidate

Another option is to serve the old value for a short period while refreshing the cache in the background. This protects user latency when slightly stale data is acceptable.

CachedValue cached = cache.get(cacheKey);

if (cached != null && !cached.isHardExpired()) {
    if (cached.isSoftExpired()) {
        refreshExecutor.submit(() -> refresh(cacheKey, id));
    }
    return cached.value();
}

return refreshSynchronously(cacheKey, id);

This is often a strong trade-off for read-heavy systems. Users get stable latency, and the application refreshes data without forcing every request to wait.

Engineering Lessons

  • A low average latency does not prove the API is healthy.
  • Redis TTL expiration can create predictable latency spikes.
  • Hot keys need different treatment from ordinary cache entries.
  • P95, P99, max latency, and hit ratio matter more than average alone.
  • Cache stampede prevention is part of production cache design, not an optional polish step.

Final Thoughts

Redis caching is powerful, but a cache is not just a fast lookup. It has lifecycle behavior. Expiration, rebuilds, and hot keys all shape production latency.

The API in this experiment looked excellent by average latency. The real story appeared only when looking at the slowest requests and correlating them with cache expiration.

The most dangerous performance issues are often the ones your dashboard smooths away.