← Back to the section

Three closely related topics about making code run not "right now in response to a request", but on a schedule or in the background. We'll cover it from scratch: how to run tasks on a timer (@Scheduled), how to move long work into a separate thread (@Async), and what Java 21 virtual threads change.

Why run code on a schedule

An application often needs to do something on its own, without a request from a user: clean up old orders once a day, publish metrics once a minute, poll an external system every 15 minutes.

In the past, people would write a separate program for this and hook it up to the system scheduler (cron on Linux). That works, but you end up with a second piece of code that has to be built, deployed, and monitored separately.

Spring can do this inside the application itself. It's enough to enable the scheduler and mark a method with the @Scheduled annotation — Spring will call it on a timer for you.

@Configuration
@EnableScheduling          // enable the scheduler
public class AppConfig { }
@Component
public class OrderCleanupJob {

    private final OrderRepository orderRepo;

    OrderCleanupJob(OrderRepository orderRepo) {
        this.orderRepo = orderRepo;
    }

    @Scheduled(cron = "0 0 3 * * *", zone = "Europe/Moscow")  // every day at 3 a.m.
    public void cleanupAbandoned() {
        orderRepo.deleteAbandonedBefore(Instant.now().minus(30, ChronoUnit.DAYS));
    }
}

A @Scheduled method must not take parameters and usually returns nothing — Spring simply invokes it at the right moment.

fixedDelay, fixedRate and cron — three ways to set a schedule

The schedule is set with one of three attributes, and the difference between them is a frequent source of bugs.

fixedDelay — the pause between the end of one run and the start of the next. If a task takes 50 seconds and fixedDelay = 60_000 (60 seconds), then the next run happens 50 + 60 = 110 seconds after the start. Runs never overlap — this is the safest option for tasks like cleaning up data.

fixedRate — the pause between the start of one run and the start of the next. If you set fixedRate = 60_000, Spring tries to run the method exactly once a minute. But if the task ever takes longer than a minute, the next run may go in parallel. You need this when even periodicity matters (for example, regularly sending metrics).

@Scheduled(fixedDelay = 60_000)   // 60 seconds after the previous one finishes
public void publishMetrics() { }

@Scheduled(fixedRate = 30_000)    // every 30 seconds from start to start
public void sampleQueueSize() { }

cron — for schedules more complex than "every N seconds": "every day at 3 a.m.", "on weekdays at 9 a.m." Spring uses a cron of six fields: second minute hour day_of_month month day_of_week.

@Scheduled(cron = "0 0 3 * * *")        // every day at 3:00:00
@Scheduled(cron = "0 */15 * * * *")     // every 15 minutes
@Scheduled(cron = "0 0 9 * * MON-FRI")  // at 9:00 on weekdays

Two useful rules. First, always specify zone for cron — otherwise the schedule is computed in the server's time zone, which may turn out not to be the one you expect. Second, it's better to move the schedule itself into configuration so you can change it without a rebuild:

@Scheduled(cron = "${app.cleanup.cron}")
public void cleanup() { }

By default all tasks share a single thread

A non-obvious pitfall: if you simply put @Scheduled on several methods, Spring runs them all in one thread. While one task is working, the others wait in a queue. One slow task delays all the rest.

To let tasks run in parallel, you set up a thread pool for the scheduler — a TaskScheduler bean:

@Configuration
@EnableScheduling
public class SchedulingConfig {

    @Bean
    public TaskScheduler taskScheduler() {
        var scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);                  // up to 5 tasks in parallel
        scheduler.setThreadNamePrefix("scheduled-"); // clear names in logs
        return scheduler;
    }
}

The pool size is how many tasks can run simultaneously. A clear prefix in the thread name helps a lot when you're going through logs looking for who is slowing down what.

One application in several copies will run a task several times

When the application runs as a single instance, everything is simple. But in real operation it's usually run in several copies for reliability. And here a problem shows up: a @Scheduled task fires on each copy. Three copies — that means the database got cleaned three times, the email got sent three times.

The simplest and most reliable solution is a shared lock via the database. The ShedLock library: before running a task, a copy tries to acquire a lock in a shared table. Whoever gets there first runs the task; the rest see that the lock is taken and just skip this run.

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulingConfig {

    @Bean
    public LockProvider lockProvider(DataSource ds) {
        return new JdbcTemplateLockProvider(ds);
    }
}

@Component
public class OrderCleanupJob {

    @Scheduled(cron = "0 0 3 * * *")
    @SchedulerLock(name = "orderCleanup")   // the lock name must be unique
    public void cleanup() { }
}

The upside of ShedLock — you don't need any separate infrastructure, the database you already have is enough. There are other approaches too (electing a "leader" copy via Kubernetes, or moving the task into a separate Kubernetes CronJob for heavy, rare work), but for most cases a shared lock is enough.

Why run code in the background with @Async

Imagine a handler that places an order and, at the end, sends a confirmation email. Sending the email goes through an external service and can take a second or two. The user waits the whole time — even though the email has nothing to do with the order itself.

It makes sense to place the order, respond to the user right away, and send the email in the background, separately. In the past, people manually created a thread or fiddled with a thread pool for this. Spring closes this gap with the @Async annotation: the marked method runs in a separate thread, and the calling code doesn't wait for it to finish.

@Configuration
@EnableAsync               // enable @Async support
public class AsyncConfig { }
@Component
public class EmailService {

    @Async
    public void sendConfirmation(String to, String body) {
        // runs in another thread, the calling code doesn't wait
    }
}

What an @Async method can return

  • void — "fire and forget". The calling code never learns how it ended and won't see an error.
  • CompletableFuture<T> — when you do need the result after all. The calling code can wait for it or handle an error.
@Async
public CompletableFuture<Report> buildReport() {
    return CompletableFuture.completedFuture(new Report(...));
}

Typical @Async pitfalls

The annotation works not by magic but through a proxy wrapper around the bean (the same mechanism as @Transactional). Two frequent mistakes follow from this.

Calling the method from the same class won't work. If a @Async method is called via this, the wrapper is bypassed, and the code runs on the ordinary thread, without any asynchrony at all.

@Service
public class MyService {

    public void process() {
        this.heavyTask();   // NOT in the background — a call via this skips the proxy
    }

    @Async
    public void heavyTask() { }
}

The solution is to move the @Async method into a separate bean and call it as a dependency.

An error from a void method is lost. With an ordinary method, an exception "bubbles up" to the calling code. With @Async void there is no one to catch it — the calling code has already moved on. So that errors don't disappear silently, you set a common handler:

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
            log.error("Async method {} failed", method.getName(), ex);
    }
}

If the method returns a CompletableFuture, there's no problem: the error ends up in the result, and the calling code will see it.

A dedicated thread pool for @Async

Without configuration, @Async uses a simple executor that creates a new thread for every call. Under load this is bad: a thousand parallel calls means a thousand threads, and the application can choke.

In most cases you set up a bounded pool — a bean of type Executor named taskExecutor:

@Bean(name = "taskExecutor")
public Executor taskExecutor() {
    var executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);     // threads that live permanently
    executor.setMaxPoolSize(32);     // maximum under peak load
    executor.setQueueCapacity(100);  // queue for when all threads are busy
    executor.setThreadNamePrefix("async-");
    executor.initialize();
    return executor;
}

This way the load is bounded: no more than maxPoolSize threads will appear, and extra tasks wait in the queue. This protects the application from overload.

Virtual threads (Java 21)

An ordinary Java thread is fairly "heavy" — each one holds operating system resources, so you can't create very many of them. That's exactly why you have to bother with pools: a pool reuses a limited number of expensive threads.

Virtual threads from Java 21 flip the situation around. These are very lightweight threads: you can create them by the thousands, and they cost almost nothing. When a virtual thread hits a wait (a database query, a network call), it doesn't hold a "real" operating system thread — that one serves other work in the meantime.

For an application that spends a lot of time waiting on I/O (and that's a typical web service: hit the database, call a neighboring service, return a response), this means something simple: you can write ordinary, readable top-to-bottom code without tricks, and still handle thousands of parallel requests.

In Spring Boot it's turned on with a single setting:

spring.threads.virtual.enabled=true

After that Spring serves each web request on a virtual thread, and @Async and @Scheduled also start working on virtual threads — they no longer need a separate pool, because creating a virtual thread per task is cheap.

What virtual threads don't do

  • They don't speed up computation. They help where the code waits on I/O. If a task loads the CPU with calculations, virtual threads give nothing — you'll hit the number of cores.
  • They don't remove the need for care with shared data. Concurrency hasn't gone anywhere: shared mutable state still needs to be protected.

In short

  • @Scheduled runs a method on a timer; enabled with the @EnableScheduling annotation.
  • fixedDelay — the pause from end to start (runs don't overlap), fixedRate — from start to start (even periodicity matters), cron — a schedule of six fields; always specify zone for cron.
  • By default all @Scheduled tasks share one thread — for parallelism, set up a TaskScheduler with a pool.
  • Across several copies of the application a task fires on each — a shared lock via ShedLock leaves a single run.
  • @Async runs a method in the background; enabled with the @EnableAsync annotation. The return type is void (no result) or CompletableFuture<T> (with a result and error).
  • @Async pitfalls: a call via this doesn't work (you need a separate bean); an error from a void method is lost (set an AsyncUncaughtExceptionHandler).
  • For @Async under load, set up a bounded ThreadPoolTaskExecutor pool (taskExecutor).
  • Virtual threads (Java 21, spring.threads.virtual.enabled=true) make concurrency cheap for I/O-bound tasks; they don't speed up computation.
  • Spring Events — @Async together with @EventListener.
  • Spring WebFlux — a different approach to concurrent work.
  • @Transactional in depth — the same proxy mechanism and the same pitfalls with calling via this.