Spring Boot Redis Benchmark: When Caching Made Latency Worse

We added Redis because the database query was expensive.

The table had 10 million rows. The endpoint was read-heavy. The query pattern looked cacheable. On paper, this was exactly the kind of workload where Redis should help.

And in one scenario, it did.

With a proper cache, throughput jumped from 630 requests per second to 9,333 requests per second. Average latency fell from 158 ms to 10.5 ms.

But then we ran a second cached version and the result was strange.

The average looked better, but the median and P95 still behaved almost like the database-bound baseline.

That is the trap this benchmark exposed: caching can make the dashboard look better while the user-facing latency distribution is still unhealthy.


The Benchmark Setup

The benchmark used a Spring Boot 3 application backed by PostgreSQL and Redis.

The test was intentionally simple: run the same read-heavy endpoint under sustained load and compare three versions of the code.

  • PostgreSQL with 10 million rows
  • Redis cache layer
  • k6 load testing
  • 100 concurrent virtual users
  • 60-second sustained load
  • Prometheus and Grafana available for observation
  • Local machine benchmark environment

The load script used 100 VUs for 60 seconds:

export const options = {
  vus: 100,
  duration: '60s'
};

The request targeted an endpoint that fetched expensive products by price. The simulated database cost made the cache behavior visible instead of hiding it behind noise.


The Three Scenarios

The benchmark compared three versions of the same operation.

ScenarioCache BehaviorIntent
A – No CacheAlways reads from PostgreSQLBaseline
B – Proper CacheCaches by priceExpected Redis win
C – Misconfigured CacheCaches and evicts on the same read pathFailure case

The code difference was small, which is why this kind of bug is easy to miss in review.

// Scenario A - No Cache
public List<Product> getExpensiveProductsA(Double price) {
    simulateSlowQuery();
    return repository.findExpensiveProducts(price);
}

// Scenario B - Proper Cache
@Cacheable(value = "expensiveProducts", key = "#price")
public List<Product> getExpensiveProductsB(Double price) {
    simulateSlowQuery();
    return repository.findExpensiveProducts(price);
}

// Scenario C - Cache Gone Wrong
@Cacheable(value = "expensiveProducts", key = "#price")
@CacheEvict(value = "expensiveProducts", allEntries = true)
public List<Product> getExpensiveProductsC(Double price) {
    simulateSlowQuery();
    return repository.findExpensiveProducts(price);
}

Scenario C is the dangerous one. It uses @Cacheable and @CacheEvict(allEntries = true) on the same read method.

That means the method tries to benefit from the cache while also invalidating the cache. Under load, this creates inconsistent behavior: some requests get fast responses, while many still fall back to the slow path.


Measured Results

These were the benchmark results from the sustained 100-VU run.

ScenarioThroughputAvg LatencyMedianP95MaxErrors
No Cache630 req/s158 ms169 ms519 ms0%
Proper Redis Cache9,333 req/s10.5 ms14.9 ms537 ms0%
Misconfigured Cache1,130 req/s88 ms152 ms157 ms674 ms0%

At first glance, Scenario C looks like an improvement over no cache. Throughput increased from 630 req/s to 1,130 req/s. Average latency dropped from 158 ms to 88 ms.

But the latency distribution tells a different story.

The median was 152 ms and the P95 was 157 ms. That is almost the same shape as the no-cache baseline.

A lower average did not mean the cache was healthy. It meant a minority of very fast responses pulled the average down.


The First Wrong Explanation

The easy explanation would be: Redis helped, but not as much as expected.

That is technically true, but it misses the important failure mode.

Scenario C did not merely underperform. It produced a mixed distribution. Some requests were cache-fast. Many requests were still database-slow.

That is worse than a clean baseline in one specific way: it makes the system harder to reason about.

With no cache, the service was slower but predictable. With the broken cache, the average improved while the tail remained close to baseline. That is exactly how a performance regression gets hidden in production dashboards.


Root Cause

The root cause was cache churn.

The cache key itself was simple: #price. That part was fine for this benchmark because every request used the same price value.

The broken part was invalidation. Scenario C evicted all entries while also using the cache for the same read operation.

  • Request checks Redis for expensiveProducts::100.
  • If the value is present, the request can be fast.
  • The same method also evicts all entries.
  • The next request often misses the cache.
  • The database path runs again.
  • The benchmark becomes a mixture of hits and misses.

That explains the strange numbers.

The average latency improved because some requests were served quickly. The median and P95 stayed high because many requests still paid the database cost.


Why Proper Cache Was So Much Faster

Scenario B behaved like a good cache should behave for a repeated read pattern.

After warm-up, most requests could avoid the simulated expensive database path. The database stopped being the critical path for the majority of requests.

MetricNo CacheProper CacheImprovement
Throughput630 req/s9,333 req/sAbout 15x higher
Avg latency158 ms10.5 msAbout 15x lower
P95 latency169 ms14.9 msAbout 11x lower
Errors0%0%No reliability trade-off in this test

This is the outcome people expect when they add Redis. The cache has high reuse, the key is stable, and invalidation is not fighting the read path.


Why The Bad Cache Was Dangerous

Scenario C was not catastrophic. It had 0% errors. That makes it more subtle.

The danger is that a team might look only at average latency and declare the cache successful.

But users do not experience averages. They experience individual requests. If half the requests still feel slow, the cache has not solved the user problem.

This is why median and P95 matter.

  • Average latency can improve when a subset of requests becomes very fast.
  • Median latency shows what a typical request feels like.
  • P95 latency shows whether the slow path still affects a meaningful part of traffic.
  • Throughput shows whether the system as a whole can do more useful work.
  • Hit ratio explains whether Redis is actually absorbing the workload.

Redis Configuration

The Redis setup used string keys and JSON value serialization.

RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);

RedisSerializer<String> keySerializer = RedisSerializer.string();
RedisSerializer<Object> valueSerializer = RedisSerializer.json();

template.setKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashKeySerializer(keySerializer);
template.setHashValueSerializer(valueSerializer);

Serialization was not the main issue in this benchmark, but it is still part of the operational cost. Large values, inefficient serialization, or oversized payloads can reduce the benefit of caching even when hit ratio is good.

That is why cache performance should be measured end to end, not assumed from Redis latency alone.


What I Would Monitor In Production

If this were a production incident, I would not stop at the endpoint latency chart.

I would add cache-specific telemetry:

  • Cache hit ratio by endpoint and key pattern
  • Cache miss count
  • Eviction count
  • Redis command latency
  • Serialized payload size
  • Database query count after cache deployment
  • P95 and P99 latency split by cache hit and cache miss

The split between cache hits and misses is especially important. Without it, Scenario C can look better than it really is.

The dashboard should answer a simple question: when the request is slow, did it hit Redis or did it go to PostgreSQL?


How To Fix The Misconfigured Cache

The fix is not to remove Redis. The fix is to make cache invalidation intentional.

For this workload, the read method should not evict all cache entries on every call.

  • Keep @Cacheable on read paths that are safe to cache.
  • Move @CacheEvict to write paths that change the underlying data.
  • Avoid allEntries = true unless the blast radius is acceptable.
  • Use stable keys that match the query shape.
  • Measure hit ratio after deployment, not just endpoint latency.

A healthy cache strategy needs both sides: reads that can reuse data and writes that invalidate only the data that became stale.


Engineering Lessons

  • Caching is a latency distribution problem, not only an average-latency problem.
  • A cache with low or unstable hit ratio can hide behind a better average.
  • Evicting on the read path can destroy the benefit of @Cacheable.
  • No cache can be slower but easier to reason about than a broken cache.
  • Production cache dashboards need hit ratio, miss rate, eviction count, and tail latency.

Source Code And Benchmark Project

The complete Spring Boot project, Docker setup, load test script, and raw benchmark results are available on GitHub:

The repository includes:

  • Full Spring Boot source code
  • Docker Compose configuration
  • Redis and PostgreSQL setup
  • k6 load test script
  • Prometheus and Grafana monitoring configuration
  • Raw benchmark results for the three scenarios

Conclusion

Redis did exactly what Redis is good at in Scenario B: it removed repeated database work from a hot read path and delivered a major throughput improvement.

But Scenario C is the lesson worth remembering.

The cache did not fail loudly. It improved the average while leaving median and P95 latency close to the no-cache baseline.

That is how caching makes performance worse from an engineering perspective: not always by increasing every number, but by making the system less predictable and the dashboard easier to misread.

Measure the distribution. Measure the hit ratio. Treat invalidation as part of the design, not as an annotation added at the end.