java

Build a Two-Tier Cache in Spring Boot with Caffeine and Redis

Learn how to build a two-tier cache in Spring Boot using Caffeine and Redis to boost performance, reduce DB load, and scale faster.

Build a Two-Tier Cache in Spring Boot with Caffeine and Redis

I’ve been thinking about speed. Not just any speed, but the kind that keeps users happy and systems responsive under real pressure. In my work with high-traffic applications, I often hit a wall where a single caching strategy just isn’t enough. The local cache is lightning fast but isolated. The distributed cache is shared but comes with a network cost. What if we didn’t have to choose? What if we could have both?

This brings me to a powerful idea: combining two caches into one cohesive system. Imagine a fast, in-memory store right inside your application, backed by a robust, shared cache that every instance can access. The goal is simple—serve most data from the closest, fastest possible place.

Why does this matter now? Modern applications demand millisecond responses. When a popular item goes on sale and thousands hit your product page at once, where does the data come from? The database? That’s a sure path to slowdowns. A single cache layer? It’s a compromise. The solution is a layered approach, and today, I want to show you how to build it.

Let’s start with the basics. We need two primary tools. First, Caffeine. It’s a high-performance cache library for Java. It stores data right in your application’s memory, allowing near-instant access. Second, Redis. It’s an in-memory data structure store that works as a distributed cache. Multiple application instances can share it.

The strategy is straightforward. When your app needs data, it checks the local Caffeine cache first. If the data is there, that’s a hit. You get an answer in microseconds. If it’s not there, that’s a miss. The app then checks the shared Redis cache. If found in Redis, the data is brought into the local Caffeine cache for next time, then returned. If it’s not in Redis either, the app fetches it from the primary source—like a database—stores it in both Redis and Caffeine, and then returns it.

How do we set this up in a Spring Boot project? We begin by adding the necessary libraries to our project configuration file.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Next, we configure the two caches. For Caffeine, we define its behavior in a configuration class.

@Configuration
public class CacheConfig {

    @Bean
    public CaffeineCacheManager caffeineCacheManager() {
        Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
                .maximumSize(1000)
                .expireAfterWrite(5, TimeUnit.MINUTES);
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(caffeine);
        return manager;
    }
}

This code creates a Caffeine cache that holds up to 1000 entries and removes them 5 minutes after they are written.

For Redis, we set connection details in the application.properties file.

spring.data.redis.host=localhost
spring.data.redis.port=6379

Now comes the interesting part: making them work together. We create a custom manager that handles the two-tier logic. This manager decides where to look for data and in what order.

@Component
public class TwoTierCacheManager implements CacheManager {

    private final CacheManager localCacheManager; // Caffeine
    private final CacheManager remoteCacheManager; // Redis

    @Override
    public Cache getCache(String name) {
        return new TwoTierCache(
                localCacheManager.getCache(name),
                remoteCacheManager.getCache(name)
        );
    }
}

The TwoTierCache class is the brain of the operation. It implements the Cache interface. Its get method outlines the flow we discussed.

public class TwoTierCache implements Cache {

    private final Cache localCache;
    private final Cache remoteCache;

    @Override
    public ValueWrapper get(Object key) {
        // 1. Check local cache first
        ValueWrapper value = localCache.get(key);
        if (value != null) {
            return value;
        }
        
        // 2. Check remote cache
        value = remoteCache.get(key);
        if (value != null) {
            // Populate local cache for future requests
            localCache.put(key, value.get());
            return value;
        }
        
        // 3. Return null, origin will be called by @Cacheable
        return null;
    }
}

When using this in a service, the Spring @Cacheable annotation works as usual. It delegates to our new cache manager.

@Service
public class ProductService {

    @Cacheable(cacheNames = "products", cacheManager = "twoTierCacheManager")
    public Product getProductById(Long id) {
        // This method runs only if data is not in L1 or L2 cache
        return productRepository.findById(id).orElseThrow();
    }
}

What happens when data changes? We must keep the caches accurate. Using @CacheEvict removes an entry from both layers when a product is updated.

@CacheEvict(cacheNames = "products", cacheManager = "twoTierCacheManager", key = "#id")
public void updateProduct(Long id, Product newData) {
    // ... update logic in database
}

But here’s a critical question: what if another application instance updates the data? Its local Caffeine cache will be cleared, but the others won’t know. This is a challenge with any local cache. One common pattern is to use Redis pub/sub to send invalidation messages to all instances. When an instance updates data, it publishes a message like “product:123 updated”. All other instances listen and evict that key from their local Caffeine cache. This keeps them in sync.

Is all this complexity worth it? For many applications, yes. The performance gain is significant. Most reads will be served from the local cache, which is incredibly fast. The Redis layer acts as a shared, consistent backup and a fast source for cold data. The database gets a break, handling far fewer requests.

You should monitor your cache performance. How many hits are you getting in the local layer versus the remote layer? Spring Boot Actuator with Micrometer can expose these metrics. You can track ratios like cache.gets and cache.misses to see how well your system is working.

What are the pitfalls? Memory is the big one. Your local Caffeine cache uses your application’s heap. If it’s too large, it can affect your app’s performance. Tune the maximumSize and expiration policies carefully based on your data patterns. Also, remember that the local cache is not shared. Data that changes frequently might be a poor candidate for this two-tier system unless you have a strong invalidation strategy.

In the end, this architecture is about smart trade-offs. You accept some complexity in exchange for speed and scalability. You manage consistency more actively. The result is an application that feels fast to users and remains robust under load.

I built this because I was tired of slow responses and overwhelmed databases. Seeing the graphs level out and the response times drop is worth the effort. Have you faced similar bottlenecks? What’s your strategy for keeping data access quick?

If you found this walk-through helpful, please share it with your team or anyone building scalable systems. I’d love to hear about your experiences in the comments below. What caching challenges are you solving?


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: Spring Boot caching, Caffeine cache, Redis cache, two-tier cache, Java performance



Similar Posts
Blog Image
Apache Kafka Spring Security Integration: Building Event-Driven Authentication and Authorization Systems

Learn how to integrate Apache Kafka with Spring Security for real-time event-driven authentication, distributed session management, and secure microservices architecture.

Blog Image
Build Reactive Data Pipelines: Spring WebFlux, R2DBC & Kafka for High-Performance Applications

Learn to build high-performance reactive data pipelines using Spring WebFlux, R2DBC, and Apache Kafka. Master non-blocking I/O, event streaming, and backpressure handling for scalable systems.

Blog Image
Complete Guide to Building High-Performance Reactive Microservices with Spring WebFlux and R2DBC

Master Spring WebFlux, R2DBC & Redis to build high-performance reactive microservices. Complete guide with real examples, testing & optimization tips.

Blog Image
Master Virtual Threads in Spring Boot 3.2: Complete Guide to Advanced Concurrency Patterns

Master Spring Boot 3.2 virtual threads and advanced concurrency patterns. Complete guide covers implementation, database ops, HTTP clients, and production best practices.

Blog Image
Mastering Event-Driven Microservices: Spring Cloud Stream, Kafka & Avro Schema Evolution Complete Guide

Learn to build scalable event-driven microservices using Spring Cloud Stream, Apache Kafka & Avro schema evolution with complete examples & best practices.

Blog Image
Apache Kafka Spring Cloud Stream Integration: Build Scalable Event-Driven Microservices Architecture

Learn how to integrate Apache Kafka with Spring Cloud Stream to build scalable event-driven microservices. Step-by-step guide with examples included.