Distributed Caching With Redis in Spring Boot — Beyond the Basics

by Eric Hanson, Backend Developer at Clean Systems Consulting

Why the default configuration isn't enough

Spring Boot's default Redis cache configuration uses Java serialization — JdkSerializationRedisSerializer. This works but has serious production problems:

  • Java serialization is slow and produces large payloads
  • Serialized data is binary and unreadable in Redis CLI — debugging requires deserialization
  • Any change to a serialized class (adding a field, renaming a class) breaks deserialization of existing cache entries
  • Java serialization is a known security risk when the deserializing class is not controlled

Configure Redis cache with JSON serialization immediately:

@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory,
            ObjectMapper objectMapper) {

        // Use a copy of ObjectMapper configured for type information
        ObjectMapper cacheMapper = objectMapper.copy()
            .activateDefaultTyping(
                objectMapper.getPolymorphicTypeValidator(),
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
            );

        RedisSerializer<Object> jsonSerializer =
            new GenericJackson2JsonRedisSerializer(cacheMapper);

        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
            .disableCachingNullValues();

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withCacheConfiguration("products",
                defaultConfig.entryTtl(Duration.ofHours(1)))
            .withCacheConfiguration("user-sessions",
                defaultConfig.entryTtl(Duration.ofMinutes(30)))
            .build();
    }
}

activateDefaultTyping embeds the Java class name in the JSON so Spring knows which class to deserialize into. This is necessary because Redis stores values without type context — without the type hint, Jackson can't reconstruct the original object.

The tradeoff: the embedded type name couples the cached JSON to the Java class name. Renaming or moving a class without invalidating the cache produces deserialization failures. Two mitigation strategies:

Version the cache key prefix when making class changes:

// Cache v1: "products::v1::123"
// After renaming ProductResponse to ProductSummaryResponse, bump to v2
@Cacheable(value = "products", key = "'v2::' + #productId")
public ProductSummaryResponse getProduct(Long productId) { ... }

Old entries under v1:: are ignored (different key) and eventually expire. New entries under v2:: use the new class name.

Use dedicated cache DTOs. Cache serialization-specific DTOs rather than domain objects. DTOs have stable names and field sets:

// Domain object — may be refactored
public class Product { ... }

// Cache DTO — stable contract, changes deliberately
public record CachedProduct(Long id, String name, long priceInCents, String currency) {
    public static CachedProduct from(Product p) {
        return new CachedProduct(p.getId(), p.getName(),
            p.getPrice().amountInCents(), p.getPrice().currency().code());
    }
}

Key design at scale

Redis keys are strings. Good key design affects memory efficiency, debugging ease, and cache invalidation patterns.

Namespace prefix. Prefix all keys with the application and cache name:

myapp:products:123
myapp:user-sessions:user-456
myapp:rates:USD:EUR

Without a prefix, cache keys from different applications or different caches on the same Redis instance collide. Spring Boot adds the cache name automatically — RedisCacheConfiguration.computePrefixWith() controls the format:

RedisCacheConfiguration.defaultCacheConfig()
    .computePrefixWith(cacheName -> "myapp:" + cacheName + ":")

Avoid high-cardinality keys with short TTLs. A cache key per user per request (user-123:orders:page-1, user-456:orders:page-1) with a 1-minute TTL generates millions of keys. Redis handles millions of keys fine, but the key space becomes a memory management problem and SCAN-based operations (monitoring, bulk eviction) become expensive.

Pattern-based invalidation with key sets. When you need to invalidate all cache entries for a particular user or entity, maintaining a set of keys is more reliable than SCAN with a glob pattern (SCAN 0 MATCH user-123:*). SCAN with patterns is O(n) over all keys:

// Track keys for pattern-based invalidation
public void cacheUserData(String userId, String type, Object data) {
    String key = "user:" + userId + ":" + type;
    redisTemplate.opsForValue().set(key, data, Duration.ofMinutes(30));

    // Track this key in the user's key set for batch invalidation
    redisTemplate.opsForSet().add("user-keys:" + userId, key);
    redisTemplate.expire("user-keys:" + userId, Duration.ofMinutes(35));
}

public void invalidateUser(String userId) {
    Set<String> keys = redisTemplate.opsForSet().members("user-keys:" + userId);
    if (keys != null && !keys.isEmpty()) {
        redisTemplate.delete(keys);
    }
    redisTemplate.delete("user-keys:" + userId);
}

Cache-aside vs read-through

Cache-aside (the @Cacheable model): the application checks the cache, loads from the database on miss, and populates the cache. The application manages the cache explicitly.

Read-through: the cache itself loads from the database on miss — the application always reads from the cache. Spring's @Cacheable is cache-aside; there's no built-in read-through in Spring Data Redis.

For most Spring Boot applications, cache-aside is correct. Read-through makes sense when the cache and the data source should be tightly coupled — when you want cache misses to be invisible to the application layer.

A manual read-through pattern with RedisTemplate:

@Service
public class ProductCache {

    private final RedisTemplate<String, CachedProduct> redisTemplate;
    private final ProductRepository repository;

    public CachedProduct get(Long productId) {
        String key = "products:" + productId;
        CachedProduct cached = redisTemplate.opsForValue().get(key);

        if (cached != null) {
            return cached;
        }

        // Load and populate — read-through
        CachedProduct product = repository.findById(productId)
            .map(CachedProduct::from)
            .orElseThrow(() -> new ProductNotFoundException(productId));

        redisTemplate.opsForValue().set(key, product, Duration.ofHours(1));
        return product;
    }
}

The benefit over @Cacheable: explicit control over the cache key, TTL, and error handling. The cost: more code.

Atomic operations with Lua scripts

Redis is single-threaded for command execution — but a sequence of commands from Java is not atomic. Between two GET/SET calls, another client can modify the key:

// NOT atomic — race condition between GET and SET
Long current = redisTemplate.opsForValue().get("counter:" + userId);
if (current == null) current = 0L;
redisTemplate.opsForValue().set("counter:" + userId, current + 1);

For atomic operations, use Redis commands that are inherently atomic (INCR, SETNX, GETSET) or Lua scripts:

// Atomic increment — INCR is a single Redis command
redisTemplate.opsForValue().increment("counter:" + userId);

// Atomic conditional set — Lua script
private static final RedisScript<Boolean> SET_IF_LESS = RedisScript.of("""
    local current = tonumber(redis.call('GET', KEYS[1]))
    if current == nil or current < tonumber(ARGV[1]) then
        redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
        return 1
    end
    return 0
    """, Boolean.class);

public boolean setIfLess(String key, long value, Duration ttl) {
    return Boolean.TRUE.equals(redisTemplate.execute(
        SET_IF_LESS,
        List.of(key),
        String.valueOf(value),
        String.valueOf(ttl.getSeconds())
    ));
}

Lua scripts execute atomically on the Redis server — no other commands run between lines of the script. Use them for conditional operations, atomic multi-key operations, and rate limiting logic.

Rate limiting with Redis

Redis's atomic operations make it the natural choice for distributed rate limiting. A sliding window counter using INCR and EXPIRE:

@Service
public class RateLimiter {

    private final RedisTemplate<String, Long> redisTemplate;

    public boolean isAllowed(String identifier, int maxRequests, Duration window) {
        String key = "rate:" + identifier + ":" +
            (System.currentTimeMillis() / window.toMillis()); // window bucket

        Long count = redisTemplate.opsForValue().increment(key);
        if (count == 1) {
            redisTemplate.expire(key, window.multipliedBy(2)); // TTL = 2 windows
        }

        return count <= maxRequests;
    }
}

Each time window gets its own key (bucketed by current window). The counter increments atomically. The key expires automatically — no cleanup required.

For more precise sliding window rate limiting, the sorted set approach tracks individual request timestamps:

public boolean isAllowedSlidingWindow(String identifier, int maxRequests, Duration window) {
    String key = "sliding-rate:" + identifier;
    long now = System.currentTimeMillis();
    long windowStart = now - window.toMillis();

    redisTemplate.execute(new SessionCallback<>() {
        @Override
        public Object execute(RedisOperations operations) {
            operations.multi();
            operations.opsForZSet().removeRangeByScore(key, 0, windowStart);
            operations.opsForZSet().add(key, String.valueOf(now), now);
            operations.opsForZSet().zCard(key);
            operations.expire(key, window);
            return operations.exec();
        }
    });

    Long count = redisTemplate.opsForZSet().zCard(key);
    return count != null && count <= maxRequests;
}

Connection pool configuration

Spring Boot's Redis connection pooling via Lettuce (the default client):

spring:
  data:
    redis:
      host: redis.internal
      port: 6379
      lettuce:
        pool:
          max-active: 16      # max connections
          max-idle: 8         # max idle connections
          min-idle: 4         # min idle connections
          max-wait: 100ms     # max wait for connection from pool

Lettuce's default is not to use a connection pool — a single shared connection with pipelining. For high-concurrency Spring Boot applications, the connection pool is necessary. Without it, commands queue on a single connection and latency spikes under load.

The right max-active depends on Redis server capacity and application concurrency. Start with 16 and adjust based on redis.commands.pending (Micrometer metric) — sustained pending commands indicate pool exhaustion.

Monitoring Redis performance

Micrometer auto-configures Redis metrics when Spring Boot Actuator is present:

redis.commands.command.count{command="GET"} — command throughput
redis.commands.command.latency.percentile{command="GET", quantile="0.99"} — p99 command latency
spring.cache.gets{name="products", result="miss"} — application-level cache miss rate
spring.cache.gets{name="products", result="hit"} — application-level cache hit rate

Redis's own INFO command provides cluster-level metrics:

redis-cli INFO stats | grep -E "instantaneous_ops_per_sec|keyspace_hits|keyspace_misses"

keyspace_hits / (keyspace_hits + keyspace_misses) is the Redis-level hit rate. This differs from the application-level hit rate reported by Micrometer — Redis sees all GET commands (including those where the key doesn't exist); Micrometer sees the application's view. Both are useful.

Alert on p99 Redis command latency above 5ms — for local network Redis, latency above 5ms indicates contention, network issues, or slow commands (KEYS, SMEMBERS on large sets, SORT without LIMIT). Alert on keyspace_misses rate increase — a sudden increase in misses indicates cache invalidation or TTL expiry patterns worth investigating.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Forced In-Person Work: When Contractors Are Treated Unfairly

“We require all contractors to be onsite five days a week.” That sentence often signals a deeper misunderstanding of what contracting actually is.

Read more

New York Startups Are Rethinking Full-Time Backend Hires — Here Is Why

You posted the job listing six weeks ago. You're still interviewing — and your backend hasn't moved an inch.

Read more

How to Negotiate Without Making the Client Feel Like They Lost

Good negotiation in contracting is not about winning — it is about reaching terms that both parties can feel good about, which makes the actual work go better.

Read more

How Oslo and Copenhagen Startups Cut Backend Costs Without Cutting Quality

You just ran payroll and noticed that your two backend engineers cost more than your entire sales team combined. In Oslo or Copenhagen, that's not unusual — it's just math that gets harder to justify every quarter.

Read more