Caching, Scheduling and Async
Three features that improve performance and automate work: cache repeated results, run tasks on a timer, and run slow work on another thread. All three are proxy-based, so they follow the same rules as @Transactional.
Caching
@Configuration
@EnableCaching
public class CacheConfig { }
@Service
public class ProductLookupService {
@Cacheable(cacheNames = "products", key = "#id")
public Product findProduct(Long id) {
System.out.println("Loading from database: " + id); // printed only on a cache miss
return repository.findById(id).orElseThrow();
}
@CachePut(cacheNames = "products", key = "#result.id") // update the cache with the result
public Product update(Product product) { return repository.save(product); }
@CacheEvict(cacheNames = "products", key = "#id") // remove one entry
public void delete(Long id) { repository.deleteById(id); }
@CacheEvict(cacheNames = "products", allEntries = true) // clear the cache
public void clearAll() { }
}spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m- Default cache is a simple in-memory map without expiry. Use Caffeine (single instance) or Redis (shared by many instances).
- Return objects must be serializable for Redis.
- Self-invocation does not trigger the cache, because it is proxy based.
- Cache data that is read often and changes rarely. Always define an expiry.
Scheduling
@Configuration
@EnableScheduling
public class SchedulingConfig { }
@Component
public class Jobs {
@Scheduled(fixedRate = 5000) // every 5 seconds from the START of the last run
public void heartbeat() { System.out.println("alive"); }
@Scheduled(fixedDelay = 10000, initialDelay = 3000) // 10 seconds AFTER the last run FINISHED
public void poll() { }
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Kolkata") // every day at 02:00
public void nightlyReport() { }
}| Cron field | Allowed values |
|---|---|
| second | 0-59 |
| minute | 0-59 |
| hour | 0-23 |
| day of month | 1-31 |
| month | 1-12 |
| day of week | 0-7 (0 and 7 are Sunday) or MON-SUN |
- The default scheduler has ONE thread. A long job delays the others. Set spring.task.scheduling.pool.size=5 if you have several jobs.
- If you run 3 instances of your application, the job runs 3 times. Use a lock such as ShedLock or a scheduler like Quartz for cluster-safe jobs.
Async
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(100);
ex.setThreadNamePrefix("async-");
ex.initialize();
return ex;
}
}
@Service
public class NotificationService {
@Async
public CompletableFuture<String> sendEmail(String to) {
// slow work runs on an "async-" thread
return CompletableFuture.completedFuture("sent to " + to);
}
@Async
public void fireAndForget() { /* exceptions are only logged, nobody can catch them */ }
}- The method must be called from another bean (proxy rule) and be public.
- Return void, Future or CompletableFuture. Exceptions of void methods need an AsyncUncaughtExceptionHandler.
- The transaction and the security context do not move to the new thread automatically.
Application events
public record OrderPlacedEvent(Long orderId) { }
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
public OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; }
@Transactional
public void place(Long id) {
// save order ...
publisher.publishEvent(new OrderPlacedEvent(id));
}
}
@Component
public class OrderListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // only after a successful commit
public void on(OrderPlacedEvent event) { System.out.println("Send email for " + event.orderId()); }
}Cache internals: what happens on a @Cacheable call
caller ─► proxy ─► CacheInterceptor
1. resolve the caches (cacheNames) with CacheResolver / CacheManager (ConcurrentMapCacheManager, CaffeineCacheManager, RedisCacheManager)
2. compute the KEY: your SpEL key, or KeyGenerator (default SimpleKeyGenerator: no args = SimpleKey.EMPTY, one arg = the arg, many = SimpleKey of all)
3. condition (SpEL) false ─► skip caching entirely
4. cache.get(key) found ─► return the cached value, method is NOT called
5. miss ─► call the method ─► unless (SpEL on #result) true ─► do not store ─► else cache.put(key, result)
@CacheEvict / @CachePut run before or after as configured (beforeInvocation)Stampede: many threads miss at the same time
@Cacheable(cacheNames = "prices", key = "#sku", sync = true) // only ONE thread computes, the others wait for the result
public Price loadPrice(String sku) { return slowPricingApi.fetch(sku); }
@Cacheable(cacheNames = "users", key = "#id", unless = "#result == null", condition = "#id > 0")
public User findUser(long id) { return repository.findById(id).orElse(null); } // do not cache null resultsDifferent TTL per cache (Caffeine)
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
CacheManager cacheManager() {
SimpleCacheManager manager = new SimpleCacheManager();
manager.setCaches(List.of(
new CaffeineCache("prices", Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(Duration.ofMinutes(5)).recordStats().build()),
new CaffeineCache("countries", Caffeine.newBuilder().maximumSize(300).expireAfterWrite(Duration.ofHours(24)).build())));
return manager;
}
@Bean
KeyGenerator tenantKeyGenerator() {
return (target, method, params) -> TenantContext.current() + ":" + method.getName() + ":" + Arrays.deepToString(params);
}
}| Cache rule | Why |
|---|---|
| Cache immutable or slowly changing data | Stale data is the main cache bug |
| Always set maximum size and TTL | Otherwise memory grows and data never refreshes |
| Design the key: include tenant, locale, user when the result depends on them | Wrong keys leak data between users |
| Evict on update or use a short TTL | Keep it consistent |
| Remember: local cache per instance | Instances can show different values. Use Redis for shared state |
Scheduling internals and cluster safety
@EnableScheduling ─► ScheduledAnnotationBeanPostProcessor finds @Scheduled methods ─► registers them in a TaskScheduler
Boot's default scheduler has ONE thread (spring.task.scheduling.pool.size=1): a job that runs long delays all the others.
Three instances of your app = every job runs THREE times (there is no coordination).// ShedLock: only one instance runs the job at a time (lock stored in a database table or Redis)
@Component
public class NightlyJobs {
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Kolkata")
@SchedulerLock(name = "nightlyReport", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M")
public void nightlyReport() { /* runs on ONE instance */ }
}
// needs @EnableSchedulerLock(defaultLockAtMostFor = "PT10M") and a LockProvider bean (JdbcTemplateLockProvider or RedisLockProvider)
// On Kubernetes an alternative is a CronJob that starts a separate short-lived pod or a call to an internal endpoint.Async internals
- @EnableAsync adds an AsyncAnnotationBeanPostProcessor that wraps beans with @Async methods in a proxy (self invocation does not work).
- The proxy submits the call to an Executor. Boot provides applicationTaskExecutor (spring.task.execution.pool.core-size, max-size, queue-capacity). If the queue is full and max-size is reached, RejectedExecutionException is thrown.
- With virtual threads enabled (spring.threads.virtual.enabled=true) the auto-configured executor uses virtual threads.
- Exceptions in void @Async methods go to an AsyncUncaughtExceptionHandler. Methods returning CompletableFuture deliver the exception through the future.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> log.error("Async method {} failed with args {}", method.getName(), Arrays.toString(params), ex);
}
}
@Async("reportExecutor") // choose a specific executor bean by name
public CompletableFuture<Report> buildReport(long id) { return CompletableFuture.completedFuture(generate(id)); }Common mistake: Caching mutable entities that are changed later or caching without expiry. Users see old data, and memory grows. Cache immutable, frequently read data with a time-to-live.
Interview questions
fixedRate or fixedDelay?
fixedRate starts a run at fixed intervals regardless of the last run. fixedDelay waits the given time after the previous run finished.
Why does @Cacheable not work?
Common reasons: @EnableCaching is missing, the method is called from the same class, the method is private, or the key is different than expected.
Can @Async and @Transactional be combined?
Yes, but the async method starts its own transaction on a new thread. It does not join the caller's transaction.
Why does @Cacheable(sync = true) matter?
Without it, when a popular entry expires, many threads call the slow method at once (cache stampede). With sync=true, one thread loads the value and the others wait.
How do I run a @Scheduled job only once in a cluster?
Use a distributed lock such as ShedLock, a Quartz cluster, or run the job as a separate Kubernetes CronJob.