Optimizing High-Volume REST APIs Using Redis Caching and Spring Boot (With Load Testing Code)
The past year, during a load-testing sprint for the product catalog service at the company, I watched a single endpoint hit 12 seconds under moderate concurrency. Not 12 seconds at peak load. At 50 concurrent users. The query itself was optimized, indexes were in place, and the SQL looked fine in isolation but the moment you stacked requests, the database just couldn't keep up. That experience pushed me to finally stop treating Redis as a "maybe later" optimization and actually wire it up properly. What I found was pretty eye-opening, and I've been using the same pattern across projects ever since, including most recently on an ATS (Applicant Tracking System) we built out for a mid-size hiring team. The Problem With Just Trusting Your Database Here's the thing most tutorials skip: your database is not slow because your queries are bad. It's slow because your queries are contended . When 200 threads all want the same candidate listing at the same time, even a 20ms query becomes a problem because connection pools saturate, locks pile up, and your p99 latency quietly climbs into the seconds. On the ATS project, the worst offender was the candidate search endpoint. Recruiters would open the pipeline view, which triggered a query joining candidates, applications, job requisitions, and interview stages across four tables. In isolation, maybe 60ms. Under the load of a hiring blitz (picture 30 recruiters simultaneously refreshing the pipeline during a campus recruiting week), it fell apart completely. I spent a week tuning that query. Added a composite index, rewrote a subquery as a join, got it down to about 40ms in isolation. Then ran the load test again. Still buckled at 50 concurrent users because the database just isn't built for that kind of fan-out. The fix wasn't another index. It was keeping the result in memory so the database mostly stopped being involved. Redis operations run in microseconds. Not milliseconds. That's not a typo or marketing copy; it's just what happens when you're reading a key from RAM instead of going through a query planner, disk I/O, and a network round trip. For data that doesn't change every second (candidate profiles, job requisition details, pipeline stage configs, lookup tables for skills and departments), caching in Redis is almost always the right call. Setting Up Spring Boot 4 With Redis Caching I'll use Spring Boot 4.0 with Java 25 here. Spring Boot 4 builds on the Spring Framework 7 baseline, which means it requires Java 17 at minimum and works beautifully with Java 25's virtual threads via Project Loom. If you're still on Spring Boot 3.x, most of this still applies, but the auto-configuration package structure shifted slightly in Boot 4, so some imports will differ. First, the dependencies in your pom.xml : <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> Then the Redis config. I prefer setting this up explicitly rather than relying purely on auto-configuration, mostly because I want control over serialization. The default JDK serialization will come back to bite you when you try to inspect cache keys in Redis CLI and see gibberish. Trust me on that one. @Configuration @EnableCaching public class RedisConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { var objectMapper = new ObjectMapper() .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .activateDefaultTyping( BasicPolymorphicTypeValidator.builder() .allowIfBaseType(Object.class) .build(), ObjectMapper.DefaultTyping.EVERYTHING ); var serializer = new GenericJackson2JsonRedisSerializer(objectMapper); var config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .serializeKeysWith( RedisSerializationContext.SerializationPair.fromSerializer( new StringRedisSerializer() ) ) .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer(serializer) ) .disableCachingNullValues(); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(config) .build(); } } Notice I'm building a dedicated ObjectMapper here rather than injecting the one Spring MVC uses. This is something I got burned by on the ATS project: the global ObjectMapper had a custom serializer registered for our CandidateStatus enum, and it was producing output that Redis couldn't deserialize cleanly on the way back out. Keeping them separate avoids that whole category of problem. Also, the .activateDefaultTyping(...) call is important for polymorphic types; without it, Jackson loses the concrete class info and you'll get LinkedHashMap where you expected a real object. Your application.yml : spring: data: redis: host: localhost port: 6379 timeout: 2000ms lettuce: pool: max-active: 20 max-idle: 10 min-idle: 5 max-wait: 1000ms cache: type: redis threads: virtual: enabled: true That last bit, spring.threads.virtual.enabled: true , is a Spring Boot 4 property that tells the framework to use virtual threads for request handling. With Java 25, virtual threads are stable and fully production-ready. On the ATS service, enabling this alongside Redis caching got our thread utilization way down during high-concurrency recruiting events because blocked threads no longer held OS thread resources. The Caching Layer: ATS Example With @EnableCaching on and the RedisCacheManager wired up, here's roughly what the candidate service looked like on the ATS project. @Service public class CandidateService { private final CandidateRepository candidateRepository; private final RequisitionRepository requisitionRepository; public CandidateService( CandidateRepository candidateRepository, RequisitionRepository requisitionRepository ) { this.candidateRepository = candidateRepository; this.requisitionRepository = requisitionRepository; } @Cacheable(value = "candidates", key = "#candidateId") public CandidateDto getCandidate(Long candidateId) { return candidateRepository.findById(candidateId) .map(CandidateMapper::toDto) .orElseThrow(() -> new CandidateNotFoundException(candidateId)); } @Cacheable( value = "pipeline-view", key = "#requisitionId + '-' + #stage + '-' + #page + '-' + #size" ) public List<CandidateSummaryDto> getPipelineView( Long requisitionId, String stage, int page, int size ) { var pageable = PageRequest.of(page, size); return candidateRepository .findByRequisitionAndStage(requisitionId, stage, pageable) .stream() .map(CandidateMapper::toSummaryDto) .toList(); } @Caching(evict = { @CacheEvict(value = "candidates", key = "#dto.id"), @CacheEvict(value = "pipeline-view", allEntries = true) }) public CandidateDto updateCandidateStage(CandidateDto dto) { var entity = candidateRepository.findById(dto.id()) .orElseThrow(() -> new CandidateNotFoundException(dto.id())); entity.setStage(dto.stage()); return CandidateMapper.toDto(candidateRepository.save(entity)); } } A few things here. I switched from caching Page<T> to caching List<T> for...