Skip to content

Fix: Spring Boot @Cacheable Not Working — Cache Miss Every Time or Stale Data

FixDevs · (Updated: )

Part of:  Java & JVM Errors

Quick Answer

How to fix Spring Boot @Cacheable issues — @EnableCaching missing, self-invocation bypass, key generation, TTL configuration, cache eviction, and Caffeine vs Redis setup.

The Problem

@Cacheable doesn’t cache — the method is called on every request:

@Service
public class UserService {

    @Cacheable("users")
    public User getUserById(Long id) {
        System.out.println("Fetching from database...");  // Prints every time
        return userRepository.findById(id).orElseThrow();
    }
}

Or a call within the same class bypasses the cache:

@Service
public class OrderService {

    @Cacheable("orders")
    public List<Order> getOrdersByUser(Long userId) { ... }

    public OrderSummary getSummary(Long userId) {
        // This call skips the cache — self-invocation problem
        List<Order> orders = getOrdersByUser(userId);
        return buildSummary(orders);
    }
}

Or the cache stores stale data and @CacheEvict doesn’t clear it:

@CacheEvict(value = "users", key = "#user.id")
public void updateUser(User user) { ... }
// Cache still returns old data after update

Why This Happens

Spring’s caching abstraction is built on AOP proxies, the same machinery that powers @Transactional, @Async, and @PreAuthorize. This is the single most important fact to internalize: the cache only intercepts calls that go through the Spring proxy. If a call sidesteps the proxy — by being made from inside the same bean, by reaching a private method, by being invoked on a new MyService() instance you created yourself — the annotation does nothing and the underlying method runs every time.

@EnableCaching is the switch that turns the entire mechanism on. Without it, Spring’s CachingConfigurer infrastructure is never registered, the BeanPostProcessor that wraps cacheable beans in a proxy never runs, and @Cacheable decays into a no-op annotation. There is no warning at startup. The application looks healthy, the SQL just keeps firing.

The self-invocation trap is closely related. When OrderService.getSummary() calls this.getOrdersByUser(), the call resolves at the JVM bytecode level through INVOKEVIRTUAL on the current object — not through the Spring-generated proxy. The proxy only intercepts method invocations on the proxy reference, which means external callers (other beans, controllers) hit the cache while internal callers always miss. The fix is either to extract the cached method into a separate bean, inject the same service into itself, or fetch the proxy from the application context.

Cache key generation is another quiet failure mode. The default key strategy is SimpleKeyGenerator, which builds a SimpleKey from all method parameters. A method with no parameters uses SimpleKey.EMPTY, meaning all calls collide on one cache entry. A method whose parameter is an entity without a properly implemented equals/hashCode produces a different key on every call, so the cache fills with duplicates and the hit rate stays at zero.

Finally, @CacheEvict and @CachePut must use the same SpEL key expression as the corresponding @Cacheable. A typo — #user.id versus #userId, #result.id versus #user.id — clears a different entry than the one your reads populate, and the cache appears to be holding stale data when in reality the eviction is hitting an unused key.

Version History: Spring Cache Abstraction Evolution

Spring’s caching support has been around for over a decade, but the defaults, supported backends, and integration points have shifted enough that the right configuration depends on your version.

  • Spring 3.1 (December 2011) — introduced the cache abstraction with @Cacheable, @CacheEvict, and @CachePut. Initial support covered ConcurrentMap, Ehcache, and a custom CacheManager SPI. The @EnableCaching annotation was part of the same release.
  • Spring 4.1 (September 2014) — added JSR-107 (JCache) support. You could now annotate methods with @CacheResult, @CacheRemove, etc., for portability with non-Spring caching frameworks.
  • Spring 5.0 (September 2017) — modernized the cache abstraction with reactive types support. Mono and Flux return values gained proper caching semantics, though some edge cases (sharing cached publishers across subscribers) were not fully addressed.
  • Spring Boot 2.0 (March 2018) — Caffeine became the recommended in-memory cache, replacing Guava (which Caffeine forked from). The auto-configuration order changed so Redis cache, when on the classpath, took priority over Caffeine.
  • Spring Boot 2.6 (November 2021) — tightened the default behavior: spring.cache.cache-names is no longer pre-instantiated when using simple cache type, which means typos in cache names that previously failed silently now fail loudly on first use. This was a small breaking change that surprised a lot of teams.
  • Spring Framework 6.0 / Spring Boot 3.0 (November 2022) — the Jakarta EE 9 migration. Every javax.cache.* import became jakarta.cache.*. JCache configurations from Boot 2.x require migration; the @CacheResult annotation lives at a new package path. Combined with the move to Java 17 as the minimum, this is the biggest single discontinuity in the cache abstraction’s history.
  • Spring Boot 3.1 (May 2023) — added native image support for the cache abstraction. Many cache TTL configurations that worked under JIT failed under GraalVM AOT because reflection on Caffeine.newBuilder() was not registered. The RuntimeHintsRegistrar mechanism fixes this.
  • Spring Framework 6.1 (November 2023) — virtual threads (JDK 21) reached parity with platform threads in the cache abstraction. Cacheable methods called from a virtual thread no longer pin the carrier thread during synchronous lookups. Combined with Redis Lettuce’s non-blocking client, you can serve cache-heavy endpoints from a small thread pool.
  • Spring Boot 3.2 (November 2023) — micrometer-based cache metrics became opt-in by default. The legacy CacheStatistics interface is deprecated in favor of MeterRegistry-backed counters.
  • Spring Boot 3.4 (November 2024) — cache observability via Micrometer Tracing reached general availability. Cache get/put/evict operations now produce spans automatically, so you can trace cold paths in distributed traces. The spring.cache.cache-names property remains, but spring.cache.caffeine.spec and spring.cache.redis.time-to-live are the recommended per-backend tuning knobs.

If you are on Boot 2.x and considering an upgrade, the Boot 3.0 jump is where most caching breakage lives. Plan for the Jakarta rename to ripple through any custom KeyGenerator or CacheResolver implementations.

Fix 1: Add @EnableCaching

Add @EnableCaching to your main application class or any @Configuration class:

// SpringBootApplication already includes @SpringBootConfiguration
// but @EnableCaching must be added explicitly
@SpringBootApplication
@EnableCaching
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

// Or in a separate configuration class
@Configuration
@EnableCaching
public class CacheConfig {
    // Cache manager bean defined here
}

Fix 2: Fix Self-Invocation

Calls from within the same class bypass the Spring proxy. Use one of these approaches:

// Option 1 — Inject the service into itself (simplest fix)
@Service
public class OrderService {

    @Autowired
    private OrderService self;  // Spring injects the proxy

    @Cacheable("orders")
    public List<Order> getOrdersByUser(Long userId) { ... }

    public OrderSummary getSummary(Long userId) {
        // Call through the proxy — cache works
        List<Order> orders = self.getOrdersByUser(userId);
        return buildSummary(orders);
    }
}
// Option 2 — Extract cached methods to a separate class
@Service
public class OrderCacheService {

    @Cacheable("orders")
    public List<Order> getOrdersByUser(Long userId) {
        return orderRepository.findByUserId(userId);
    }
}

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderCacheService orderCacheService;

    public OrderSummary getSummary(Long userId) {
        // Call through proxy — works correctly
        List<Order> orders = orderCacheService.getOrdersByUser(userId);
        return buildSummary(orders);
    }
}
// Option 3 — Get proxy from ApplicationContext
@Service
public class OrderService implements ApplicationContextAware {

    private ApplicationContext context;

    @Override
    public void setApplicationContext(ApplicationContext context) {
        this.context = context;
    }

    private OrderService getSelf() {
        return context.getBean(OrderService.class);
    }

    public OrderSummary getSummary(Long userId) {
        List<Order> orders = getSelf().getOrdersByUser(userId);
        return buildSummary(orders);
    }
}

Note: Option 2 (separate class) is the cleanest approach and avoids circular dependency risks.

Fix 3: Configure Cache Keys Correctly

The default key is all method parameters. Specify keys explicitly for predictable behavior:

// Default key — uses all parameters (method.simpleKey for single param)
@Cacheable("users")
public User getById(Long id) { ... }
// Cache key: SimpleKey[1] for id=1

// Explicit SpEL key
@Cacheable(value = "users", key = "#id")
public User getById(Long id) { ... }

// Composite key from multiple params
@Cacheable(value = "products", key = "#category + '-' + #page")
public List<Product> getByCategory(String category, int page) { ... }

// Key from object field
@Cacheable(value = "users", key = "#user.id")
public User processUser(User user) { ... }

// Key with method name to avoid cross-method collisions
@Cacheable(value = "users", key = "'byEmail:' + #email")
public User getByEmail(String email) { ... }

// Conditional caching — only cache when condition is true
@Cacheable(value = "users", key = "#id", condition = "#id > 0")
public User getById(Long id) { ... }

// Unless — don't cache if result matches condition
@Cacheable(value = "users", key = "#id", unless = "#result == null")
public User getById(Long id) { ... }
// Prevents caching null results (for missing records)

Fix 4: Configure CacheEvict and CachePut

Keep the cache in sync with database updates:

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    @Cacheable(value = "users", key = "#id")
    public User getById(Long id) {
        return userRepository.findById(id).orElseThrow();
    }

    // Update cache after saving
    @CachePut(value = "users", key = "#result.id")
    public User save(User user) {
        return userRepository.save(user);
    }

    // Evict single entry
    @CacheEvict(value = "users", key = "#id")
    public void deleteById(Long id) {
        userRepository.deleteById(id);
    }

    // Evict all entries in a cache
    @CacheEvict(value = "users", allEntries = true)
    public void clearAll() { }

    // Evict before the method runs
    @CacheEvict(value = "users", key = "#user.id", beforeInvocation = true)
    public void updateUser(User user) {
        userRepository.save(user);
        // Even if this throws, cache entry is already evicted
    }

    // Multiple cache operations
    @Caching(
        put = { @CachePut(value = "users", key = "#result.id") },
        evict = { @CacheEvict(value = "userList", allEntries = true) }
    )
    public User createUser(User user) {
        return userRepository.save(user);
    }
}

Fix 5: Configure Cache Manager with TTL

In-memory caches grow unbounded without TTL. Configure a proper cache manager:

Caffeine (in-process, high performance):

<!-- pom.xml -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();

        // Default spec for all caches
        manager.setCaffeineSpec(CaffeineSpec.parse("maximumSize=1000,expireAfterWrite=10m"));
        return manager;
    }

    // Or configure per-cache TTL
    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager manager = new SimpleCacheManager();

        List<CaffeineCache> caches = List.of(
            buildCache("users", 500, Duration.ofMinutes(30)),
            buildCache("products", 1000, Duration.ofHours(1)),
            buildCache("sessions", 10000, Duration.ofMinutes(15))
        );

        manager.setCaches(caches);
        return manager;
    }

    private CaffeineCache buildCache(String name, int maxSize, Duration ttl) {
        return new CaffeineCache(name, Caffeine.newBuilder()
            .maximumSize(maxSize)
            .expireAfterWrite(ttl)
            .recordStats()  // Enable cache hit/miss statistics
            .build());
    }
}

Redis (distributed, survives restarts):

# application.yml
spring:
  cache:
    type: redis
    redis:
      time-to-live: 600000  # 10 minutes in milliseconds (default for all caches)
  redis:
    host: localhost
    port: 6379
@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        // Default config for all caches
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
            .defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
            )
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer()
                )
            );

        // Per-cache overrides
        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
            "users", defaultConfig.entryTtl(Duration.ofMinutes(30)),
            "sessions", defaultConfig.entryTtl(Duration.ofMinutes(5)),
            "products", defaultConfig.entryTtl(Duration.ofHours(2))
        );

        return RedisCacheManager.builder(factory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }
}

Fix 6: Monitor Cache Effectiveness

Enable logging and metrics to verify the cache is working:

# application.yml — enable cache debug logging
logging:
  level:
    org.springframework.cache: TRACE
// Check cache statistics with Caffeine
@Autowired
private CacheManager cacheManager;

public void printStats() {
    CaffeineCache cache = (CaffeineCache) cacheManager.getCache("users");
    CacheStats stats = cache.getNativeCache().stats();
    System.out.println("Hit rate: " + stats.hitRate());
    System.out.println("Hits: " + stats.hitCount());
    System.out.println("Misses: " + stats.missCount());
}

// Expose via Spring Boot Actuator
// GET /actuator/caches — lists all caches
// GET /actuator/caches/users — details for 'users' cache
// DELETE /actuator/caches/users — clear 'users' cache
# application.yml — enable cache actuator endpoint
management:
  endpoints:
    web:
      exposure:
        include: caches, health, info

Still Not Working?

@Cacheable on private or final methods — Spring AOP can’t proxy private or final methods. The annotation is silently ignored. Make the method public and non-final.

Entities not serializable for Redis — when using Redis as the cache store, cached objects must be serializable. If you’re caching JPA entities, they must implement Serializable or you must use a custom serializer. Use DTOs instead of entities to avoid Hibernate lazy-loading issues.

Cache key type mismatch — if the key is generated from an object, ensure the object implements equals() and hashCode() correctly. Two objects that are logically equal but have different hash codes produce different cache keys and result in cache misses.

Testing with @Cacheable — Spring’s test context reuses application context across tests by default. Cache entries from one test may affect another. Use @DirtiesContext or manually clear the cache in @AfterEach:

@AfterEach
void clearCache() {
    cacheManager.getCacheNames().forEach(name ->
        cacheManager.getCache(name).clear()
    );
}

Virtual threads (JDK 21) cause cache contention spikes — under heavy load on Spring 6.1+ with virtual threads, a synchronous Redis lookup that previously serialized on a small thread pool now executes thousands of concurrent network calls. The Redis client connection pool becomes the new bottleneck. Switch to Lettuce (non-blocking by default) and raise the pool size, or wrap hot reads in @Cacheable(sync = true) so only one thread populates the value per key.

GraalVM native image silently disables caching — under AOT compilation, Spring’s cache proxy creation can fail because Caffeine and Redis configuration classes use reflection that isn’t registered in the native-image hints. The app starts, no error appears, but every @Cacheable call hits the database. Add a RuntimeHintsRegistrar for your cache configuration classes and verify with -H:+TraceClassInitialization during the build.

Conditional @Cacheable evaluating SpEL against null — when condition = "#user != null" evaluates against a null parameter, SpEL throws SpelEvaluationException and the method falls through without caching but logs an error every call. The error is logged at DEBUG level by default, so it’s invisible in production. Use safe navigation (#user?.id) or move the null check into Java code before the cacheable method.

For related Spring Boot issues, see Fix: Spring Boot Transaction Not Rolling Back, Fix: Spring Data JPA Query Not Working, Fix: Spring Boot Datasource Failed, and Fix: Java Spring Bean Creation Exception.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles