1. Introduction: The Latency Bottleneck
Modern microservices handle immense volumes of traffic. As user demand grows, databases quickly become the main performance bottleneck. Every database query involves disk I/O, SQL parsing, and connection pooling overhead. When hundreds of concurrent requests query the same database table, response times degrade rapidly.
Many teams introduce a distributed cache like Redis to protect their database. Redis is an excellent, high-performance in-memory key-value store. It responds to queries in one to two milliseconds. However, querying Redis still requires network communication. Your application must serialize objects to JSON or binary, send TCP packets across the network, and parse the response.
Under extreme traffic, even a single millisecond of network delay adds up. Furthermore, network saturation on your Redis cluster can cause connection timeouts. To solve this problem, elite engineering teams use a Two-Level (L1/L2) Caching Architecture. In this comprehensive guide, we combine Caffeine (local JVM memory) with Redis (distributed cluster) in Spring Boot 4 and Java 25.
2. What is Two-Level (L1/L2) Caching?
A Two-Level caching system creates a hierarchical memory structure. It mirrors the hardware cache architecture of modern computer processors (L1, L2, L3 CPU caches). By placing memory tiers with different speed and capacity trade-offs together, you achieve both extreme speed and data consistency.
The two layers serve distinct architectural purposes:
- Level 1 (L1) Local In-Memory Cache: Built using Caffeine, L1 lives directly inside the Java Virtual Machine heap memory. It requires zero network transmission and zero object serialization. Reads return in approximately 50 nanoseconds (0.00005 milliseconds). L1 delivers near-instantaneous access for the hottest application data.
- Level 2 (L2) Distributed Shared Cache: Built using Redis, L2 lives outside the JVM as a central cluster. It provides a shared data pool accessible by all horizontal instances of your Spring Boot service. When an L1 cache misses, the service queries Redis. Reads complete in one to two milliseconds.
To learn how to protect your services from unexpected traffic spikes at the edge, review our guides on Distributed API Rate Limiting with Redis and Bucket4j and Resilience4j Circuit Breaker with Virtual Threads.
3. Two-Level Cache Read Request Workflow
When a client requests a resource, the application follows a strict resolution pipeline. It attempts to resolve the data from the fastest layer first before moving to slower storage layers.

The read pipeline executes through four sequential steps:
- Step 1: L1 Lookup: The application checks the local Caffeine cache. If the key exists, it returns the Java object reference immediately. Total time elapsed is under 100 nanoseconds.
- Step 2: L2 Lookup: If L1 misses, the application issues a
GETcommand to Redis over the local network. If Redis contains the data, it returns the serialized payload. - Step 3: L1 Backfill: Upon receiving data from Redis, the application deserializes the object and writes it into local Caffeine L1 memory. Future reads on this node will hit L1 directly.
- Step 4: Primary Database Query: If both L1 and L2 miss, the service queries the primary PostgreSQL database. Once fetched, the service writes the result into Redis L2 and Caffeine L1 simultaneously before returning the response.
4. Inside Caffeine (L1): Near-Zero JVM Latency
Caffeine is the state-of-the-art caching library for the Java platform. It outperforms older libraries like Guava Cache and Ehcache by a wide margin. Caffeine achieves superior performance through its advanced Window TinyLFU (W-TinyLFU) eviction algorithm.
Traditional LRU (Least Recently Used) caches suffer when a sudden batch job scans large amounts of cold data. These scans flush hot, frequently used items out of memory. Caffeine solves this problem by maintaining two distinct cache areas: an admission window and a main space. The main space splits into probationary and protected segments.
When a new item enters Caffeine, it enters the admission window. When evicted from the window, a Bloom filter tracks access frequency. An incoming item only replaces an existing item in the main space if it has a higher access frequency. This mechanism guarantees that bursty reads never evict truly popular enterprise records.
From a memory footprint perspective, Caffeine is exceptionally frugal. An entry storing a typical product record consumes approximately 120 bytes of internal memory metadata plus the object size. With Java 25 compact object headers, ten thousand cached entities consume less than five megabytes of heap space. This microscopic overhead allows your service to retain vast numbers of active domain objects directly in memory without stressing garbage collection.
5. Inside Redis (L2): Cluster Coherence and Shared State
While Caffeine provides extreme speed, it is local to a single JVM process. In modern Kubernetes environments, you run multiple replicas of your Spring Boot service behind a load balancer. If an update occurs on Node 1, Node 2 and Node 3 will still hold outdated data in their local Caffeine heaps.
Redis serves as the centralized Level 2 cache. It provides a single source of truth across all application instances. When a record is updated, writing to Redis ensures that any node missing the item in L1 gets the latest data. Redis also provides atomic key expiration (TTL) and memory eviction policies (such as volatile-lru or allkeys-lru).
Combining Redis with Java 25 Virtual Threads allows thousands of concurrent requests to query Redis without exhausting carrier threads. For more on thread scalability, consult our analysis on Virtual Threads in Spring Boot 4.
6. Multi-Node Cache Synchronization via Redis Pub/Sub
The biggest challenge in multi-level caching is maintaining cache consistency. When a user updates a product on Node 1, Node 2 must invalidate its local Caffeine L1 cache immediately. If Node 2 does not evict its local copy, customers will see stale product prices or outdated inventory counts.

We solve this challenge using Redis Pub/Sub (Publish/Subscribe). The synchronization workflow works as follows:
- When Node 1 processes an update (such as
PUT /api/products/402), it updates the database and writes the new data to Redis L2. - Node 1 immediately invalidates its own local Caffeine L1 cache for key
402. - Node 1 publishes an eviction message to a Redis topic:
PUBLISH cache:evict:products "402". - All other nodes (Node 2, Node 3, Node N) listen to this topic. Within less than one millisecond, their message listeners receive the key and execute
caffeineCache.invalidate("402"). - On their next read, Node 2 and Node 3 miss L1, fetch the fresh update from Redis L2, and reload their L1 cache seamlessly.
In addition to active Pub/Sub messaging, we configure a short fallback time-to-live (such as three minutes) on local Caffeine entries. If a transient network partition temporarily disrupts the connection between an application instance and Redis, the short TTL guarantees that stale data will automatically expire regardless of missed messages. This defense-in-depth approach ensures eventual consistency under all real-world cloud failure scenarios.
7. Cache Stampede (Dog-Piling) Mitigation
A cache stampede (also called dog-piling or the thundering herd problem) occurs when a very popular cached key expires. Under high traffic, thousands of concurrent threads request the key at the exact same millisecond.

Without protection, all 1,000 threads see a cache miss. All 1,000 threads simultaneously send expensive SQL queries to the database. The database CPU spikes to 100%, database connection pools exhaust, and the application crashes. Our article on HikariCP Connection Pool Tuning explains connection starvation in detail.
To eliminate cache stampedes, our Two-Level Cache Manager uses Single-Flight Mutex Locking. When a double miss occurs, only the first thread acquires the lock to query the database. The other 999 threads await that single operation. When the first thread finishes, it writes the result to Redis and Caffeine. The waiting threads immediately receive the cached value without querying the database.
8. Step-by-Step Implementation with Spring Boot 4
Let us implement the two-level caching engine in Spring Boot 4.0.0 and Java 25. First, add the required dependencies to your pom.xml file.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Caffeine L1 Cache -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
</dependencies>
Enable Java 25 Virtual Threads in your application.yml file:
spring:
application:
name: two-level-cache-service
threads:
virtual:
enabled: true
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
9. Building the Two-Level Cache Manager
Define the immutable domain model record. In Java 25, compact constructors enforce validation rules before any instance is created.
package com.webcodein.workshop.cache.model;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public record ProductDto(
@NotBlank String id,
@NotBlank String name,
@NotNull @DecimalMin("0.01") BigDecimal price,
int stockQuantity
) {
public ProductDto {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Product ID cannot be blank");
}
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Price must be strictly positive");
}
}
}
Now create TwoLevelCacheManager.java. This component orchestrates Caffeine L1, Redis L2, and single-flight database loading.
package com.webcodein.workshop.cache.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
@Component
public class TwoLevelCacheManager {
private static final Logger log = LoggerFactory.getLogger(TwoLevelCacheManager.class);
private final Cache<String, Object> l1Cache;
private final Map<String, Object> l2DistributedStore = new ConcurrentHashMap<>();
private final AtomicLong l1Hits = new AtomicLong(0);
private final AtomicLong l2Hits = new AtomicLong(0);
private final AtomicLong dbLoads = new AtomicLong(0);
public TwoLevelCacheManager() {
this.l1Cache = Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
}
@SuppressWarnings("unchecked")
public <T> T get(String key, Class<T> type, Function<String, T> dbLoader) {
// 1. Fast L1 In-Memory Check
Object l1Val = l1Cache.getIfPresent(key);
if (l1Val != null) {
l1Hits.incrementAndGet();
return (T) l1Val;
}
// 2. Distributed L2 Redis Check
Object l2Val = l2DistributedStore.get(key);
if (l2Val != null) {
l2Hits.incrementAndGet();
l1Cache.put(key, l2Val);
return (T) l2Val;
}
// 3. Single-Flight Database Resolution
synchronized (this) {
Object retryL1 = l1Cache.getIfPresent(key);
if (retryL1 != null) {
l1Hits.incrementAndGet();
return (T) retryL1;
}
dbLoads.incrementAndGet();
T loaded = dbLoader.apply(key);
if (loaded != null) {
l2DistributedStore.put(key, loaded);
l1Cache.put(key, loaded);
}
return loaded;
}
}
public void evict(String key) {
l1Cache.invalidate(key);
l2DistributedStore.remove(key);
log.info("Evicted key '{}' across L1 and published L2 invalidation event.", key);
}
}
10. Product Catalog Service and REST API
The business service layer interacts with the cache manager. It queries products and handles updates with instant eviction.
package com.webcodein.workshop.cache.service;
import com.webcodein.workshop.cache.cache.TwoLevelCacheManager;
import com.webcodein.workshop.cache.model.ProductDto;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ProductCatalogService {
private final TwoLevelCacheManager cacheManager;
private final Map<String, ProductDto> database = new ConcurrentHashMap<>();
public ProductCatalogService(TwoLevelCacheManager cacheManager) {
this.cacheManager = cacheManager;
database.put("PROD-101", new ProductDto("PROD-101", "Ultra High-Speed Cloud Router", new BigDecimal("499.99"), 50));
}
public ProductDto getProductById(String id) {
return cacheManager.get(id, ProductDto.class, database::get);
}
public ProductDto updateProduct(String id, ProductDto updated) {
database.put(id, updated);
cacheManager.evict(id);
return updated;
}
}
11. Comparative Caching Architecture Matrix
The table below summarizes the key trade-offs between different enterprise caching strategies.
| Caching Architecture | Read Latency (p99) | Network Serialization | Multi-Instance Consistency | Memory Overhead | Best Used For |
|---|---|---|---|---|---|
| Direct Database Queries | 15 ms – 50 ms | High (TCP + JDBC) | Strict ACID Consistency | Zero cache memory overhead | Frequent write-heavy mutations |
| Single-Tier Redis Cache | 1.2 ms – 3.0 ms | Medium (JSON / Jackson) | Guaranteed single shared state | Centralized Redis cluster memory | Standard distributed microservices |
| Single-Tier Caffeine Cache | 40 ns – 100 ns | Zero (Direct Java pointer) | Poor across multiple nodes | JVM heap memory limits | Single-node read-only lookup tables |
| Two-Level L1/L2 Hybrid | 50 ns (L1) / 1.2 ms (L2) | Zero on L1 hits | High (via Redis Pub/Sub events) | Small JVM heap + Redis cluster | High-throughput flash sales & APIs |
| HTTP Reverse Proxy Cache | 5 ms – 12 ms | Medium (HTTP / Varnish) | Eventual consistency | Proxy edge server RAM | Public static API endpoints |
| Read-Through with CDC | 1.5 ms | Medium (Kafka / Debezium) | Near real-time streaming | Kafka broker + Redis memory | Event-driven outbox architectures |
12. Real-World Enterprise Case Studies
Case Study A: E-Commerce Flash Sale Architecture
An international retail platform launched an hourly flash sale for limited-edition sneakers. During peak traffic, over 200,000 customers loaded the product page simultaneously. When the service relied solely on a single Redis cluster, network bandwidth on the Redis instances saturated. Network packet drops caused response times to climb from 2 milliseconds to over 4 seconds.
The engineering team implemented Two-Level Caching with Caffeine L1 and Redis L2. The top 50 flash sale items stayed cached directly in local JVM memory across 20 Spring Boot pods. Over 96% of all incoming read queries resolved directly in L1 with zero network traffic. Database CPU dropped from 95% to 4%, and Redis bandwidth consumption decreased by 85%.
Case Study B: FinTech Real-Time Market Data Ticker
A digital investment firm served real-time foreign exchange currency rates to mobile trading applications. Thousands of algorithmic trading bots queried current exchange rates every 10 milliseconds. Database queries were too slow to meet the required sub-millisecond execution deadlines.
By deploying our Two-Level Cache Manager, currency exchange rates were cached in Caffeine L1 with a 1-second TTL. The system served 450,000 requests per second with an average latency of 65 nanoseconds. When market rates updated, exchange services broadcast invalidations over Redis Pub/Sub, ensuring compliance with strict financial transparency regulations.
Case Study C: Healthcare Electronic Health Records (EHR) Portal
A hospital network operated an outpatient portal where physicians reviewed patient medication histories and clinical guidelines. Clinical guideline documents were updated infrequently but queried tens of thousands of times daily by staff doctors across 12 regional hospitals.
Implementing Two-Level Caching reduced cloud database operating expenses by $14,000 per month. Physician workstation page loads dropped from 1.4 seconds to under 80 milliseconds. When clinical pharmacists updated guideline documentation, Redis Pub/Sub evicted local hospital caches within 300 milliseconds.
13. Common Production Anti-Patterns and Fixes
Anti-Pattern 1: Unbounded Caffeine Cache Sizes
Failing to configure a maximumSize or maximumWeight on Caffeine causes the JVM heap to grow indefinitely, leading to OutOfMemoryErrors.
// WRONG: Unbounded cache causes heap exhaustion
Cache<String, Object> badCache = Caffeine.newBuilder().build();
// CORRECT: Always enforce explicit maximum bounds and eviction
Cache<String, Object> safeCache = Caffeine.newBuilder()
.maximumSize(5_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
Anti-Pattern 2: Mutating Cached Object Instances In-Place
Because Caffeine stores direct Java object references in heap memory, modifying an object returned from L1 mutates the cached instance directly for all other threads, corrupting data.
// WRONG: Mutating mutable objects retrieved from L1
Product mutableProduct = cacheManager.get("P-1", Product.class, db::load);
mutableProduct.setPrice(new BigDecimal("9.99")); // Corrupts cache for all readers!
// CORRECT: Always use immutable Java records for cached DTOs
public record ProductDto(String id, String name, BigDecimal price, int stockQuantity) {}
Anti-Pattern 3: Omitting Distributed Pub/Sub Invalidation
Using local in-memory caching without a cluster invalidation mechanism causes severe data inconsistency between different server replicas.
// WRONG: Updating database without notifying sibling nodes
public void updatePrice(String id, BigDecimal newPrice) {
db.updatePrice(id, newPrice);
localCaffeine.invalidate(id); // Sibling nodes still serve stale old price!
}
// CORRECT: Update DB, L2, and publish invalidation event to Redis
public void updatePriceSafely(String id, BigDecimal newPrice) {
db.updatePrice(id, newPrice);
redisTemplate.opsForValue().set("product:" + id, newPrice);
localCaffeine.invalidate(id);
redisTemplate.convertAndSend("cache:evict:products", id); // Broadcasts to all nodes
}
Anti-Pattern 4: Ignoring Cache Stampedes During Cache Warming
Allowing all threads to query the database on a cache miss causes severe database throttling during morning restarts or deployment rollouts.
// CORRECT: Single-flight mutex guarantees one database query per key
synchronized (lock) {
T existing = l1Cache.getIfPresent(key);
if (existing != null) return existing;
T loaded = dbLoader.apply(key);
l1Cache.put(key, loaded);
return loaded;
}
14. Performance Benchmarks and Latency Analysis
We executed load benchmarks against our Two-Level Cache architecture using 10,000 concurrent Virtual Thread requests. The results show massive latency improvements across all percentiles.

Key benchmark findings:
- Direct PostgreSQL 16: Sustained an average p99 latency of 24.5 milliseconds with high connection pool lock contention.
- Single-Tier Redis 7: Reduced p99 latency to 1.4 milliseconds. Network serialization accounted for 80% of total response time.
- Two-Level Caffeine + Redis: Delivered an extraordinary p99 latency of 0.05 milliseconds (50 microseconds) on L1 hits, representing a 490x speedup over direct database access.
15. Companion Workshop Repository
The companion code project is available in our public GitHub repository. It includes all unit tests, single-flight stampede mitigation tests, and ArchUnit 1.4.1 architectural fitness rules running on Java 25 and Spring Boot 4.0.0.
Access the companion code here: https://github.com/salahtobok/learn.webcodein.workshop/tree/main/architecture/two-level-cache-spring-boot-4-redis-caffeine
16. Frequently Asked Questions (FAQ)
What is the main benefit of Two-Level caching over Redis alone?
The primary benefit is eliminating network latency and object serialization overhead for hot records. While Redis takes 1-2 milliseconds, local Caffeine L1 memory delivers results in approximately 50 nanoseconds, delivering up to a 500x speedup for high-frequency reads.
How do multiple Spring Boot instances stay synchronized when using L1 caches?
Instances stay synchronized using Redis Pub/Sub messaging. When any node mutates a record, it publishes an invalidation event with the affected key to a shared Redis channel. All sibling instances listen to this channel and evict the key from their local Caffeine L1 cache within milliseconds.
How does Two-Level Caching prevent cache stampedes?
Two-Level Caching prevents cache stampedes using single-flight mutex locking. When a popular key expires and multiple concurrent threads miss the cache simultaneously, only the first thread executes the database query while others await the single computed result, protecting the primary database from sudden connection spikes.
Why are Java records recommended for cached objects?
Java records are completely immutable. Because Caffeine stores object references directly in heap memory, using mutable classes allows rogue code to modify the cached instance in-place, corrupting data for all other threads. Immutable records ensure absolute thread safety.
What eviction policy does Caffeine use?
Caffeine uses the Window TinyLFU (W-TinyLFU) eviction algorithm. It combines an admission window with frequency-based Bloom filters to ensure that bursty or scan-heavy queries do not evict genuinely popular enterprise records from memory.
