Home / Spring Boot / Production and Testing / Caching, Scheduling and Async

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

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 fieldAllowed values
second0-59
minute0-59
hour0-23
day of month1-31
month1-12
day of week0-7 (0 and 7 are Sunday) or MON-SUN

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 */ }
}

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 results

Different 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 ruleWhy
Cache immutable or slowly changing dataStale data is the main cache bug
Always set maximum size and TTLOtherwise memory grows and data never refreshes
Design the key: include tenant, locale, user when the result depends on themWrong keys leak data between users
Evict on update or use a short TTLKeep it consistent
Remember: local cache per instanceInstances 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

@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.