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.
The same task on three timelines. On top, fixedDelay: the pause is counted from the end of the previous run, so the starts drift to the right. In the middle, fixedRate: the starts sit on the grid, the rhythm is even. At the bottom, the same fixedRate, but the first run overran: the missed starts go back to back, catching up on the schedule — the task never runs in parallel with itself.
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 wrote a separate program for this and hooked it up to the system scheduler (cron on Linux) — a second piece of code to build, ship and maintain separately.
Spring can do this inside the application itself: enable the scheduler, mark a method with @Scheduled — and Spring will call it on a timer.
@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 takes no parameters and usually returns nothing.
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. A task takes 50 seconds and fixedDelay = 60_000 — the next start is 110 seconds after the previous one. Runs never overlap, so this is the safest option for cleaning up data.
fixedRate — the pause between the start of one run and the start of the next: with fixedRate = 60_000 the method starts once a minute. If the task ever takes longer than a minute, it still won't run in parallel with itself — the scheduler waits for it to finish and starts the next pass right away, catching up on the schedule. You need this when even periodicity matters.
@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() { }
The difference is visible without Spring too — on the same ScheduledExecutorService the scheduler runs on:
live example
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduleDemo {
static final long TICK = 120; // one "tick" is 120 ms, so the whole run takes a couple of seconds
static long start;
static int run;
static Runnable task(long... durations) {
return () -> {
long tick = Math.round((System.currentTimeMillis() - start) / (double) TICK);
System.out.println(" started at tick " + tick);
try {
Thread.sleep(durations[Math.min(run++, durations.length - 1)] * TICK);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
}
static void demo(String title, boolean rate, long... durations) throws Exception {
System.out.println(title);
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
start = System.currentTimeMillis();
run = 0;
Runnable job = task(durations);
if (rate) {
scheduler.scheduleAtFixedRate(job, 0, 3 * TICK, TimeUnit.MILLISECONDS);
} else {
scheduler.scheduleWithFixedDelay(job, 0, 3 * TICK, TimeUnit.MILLISECONDS);
}
Thread.sleep(11 * TICK);
scheduler.shutdownNow();
}
public static void main(String[] args) throws Exception {
demo("fixedDelay = 3 ticks, task takes 2 ticks:", false, 2);
demo("fixedRate = 3 ticks, task takes 2 ticks:", true, 2);
demo("fixedRate = 3 ticks, the first run overran to 5 ticks:", true, 5, 1);
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The fixedDelay starts are 0, 5, 10 — the pause is counted from the end; fixedRate starts at 0, 3, 6, 9. In the third run the first pass overran, and the starts at 5 and 6 go back to back — the scheduler catches up.
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
Always specify zone for cron — otherwise the schedule is computed in the server's time zone, which may not be the one you expect. And keep the schedule in 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 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 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 run at once; a clear thread-name prefix helps you find in the logs who is slowing things down.
One application in several copies will run a task several times
In real operation an application is usually run in several copies for reliability, and here a problem shows up: a @Scheduled task fires on each copy. Three copies means the database got cleaned three times and the email went out three times.
The simplest solution is a shared lock in the database. With the ShedLock library a copy tries to take a lock in a shared table before the run: whoever gets there first runs the task, the rest see the taken lock and skip it.
@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() { }
}
ShedLock needs no separate infrastructure — the database you already have is enough. There are other approaches (electing a "leader" copy via Kubernetes, a separate CronJob for rare heavy work), but a shared lock is enough almost always.
Why run code in the background with @Async
Imagine a handler that places an order and then sends a confirmation email. Sending goes through an external service and takes a second or two, and the user waits the whole time — though the email has nothing to do with the order.
It makes sense to place the order, respond to the user right away, and send the email in the background. That's what the @Async annotation does: 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 an @Async method is called via this, the wrapper is bypassed and the code runs on the ordinary thread. The mechanics are visible in plain Java: the wrapper here is java.lang.reflect.Proxy, "in the background" means "on thread async-1".
live example
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Proxy;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class AsyncProxyDemo {
@Retention(RetentionPolicy.RUNTIME)
@interface Async { }
interface Mailer {
void checkout(String to);
@Async void sendReceipt(String to);
}
static class MailerBean implements Mailer {
public void checkout(String to) {
System.out.println("placing the order — thread " + Thread.currentThread().getName());
this.sendReceipt(to); // a call via this: the wrapper stays aside
}
public void sendReceipt(String to) {
System.out.println(" receipt for " + to + " — thread " + Thread.currentThread().getName());
}
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newSingleThreadExecutor(r -> new Thread(r, "async-1"));
Mailer target = new MailerBean();
Mailer bean = (Mailer) Proxy.newProxyInstance( // this is how Spring wraps the bean
Mailer.class.getClassLoader(), new Class<?>[]{Mailer.class},
(proxy, method, params) -> {
if (method.isAnnotationPresent(Async.class)) {
pool.submit(() -> method.invoke(target, params));
} else {
method.invoke(target, params);
}
return null;
});
bean.checkout("anna@example.com"); // receipt from the inside — past the wrapper
Thread.sleep(150);
bean.sendReceipt("ivan@example.com"); // receipt from the outside — through the wrapper
Thread.sleep(150);
pool.shutdown();
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The first receipt went out on thread main: the call via this slipped past the wrapper; the second one, called from the outside, landed on async-1. The fix is to move the @Async method into a separate bean.
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
Spring Boot already gives you a pool, but it has eight threads and an unbounded queue: under load the threads don't multiply, instead tasks pile up in memory. From the outside everything looks healthy while latency grows, and on a restart the queue disappears along with the work it held.
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 appear, extra tasks wait in the queue.
Virtual threads (Java 21)
An ordinary Java thread is "heavy": each one holds operating system resources, so you can't create thousands of them. That's exactly why pools exist — they reuse a limited number of expensive threads.
Virtual threads from Java 21 flip the situation around: they cost almost nothing and you create them by the thousands. 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 a web service that mostly waits on I/O, this means you can write ordinary, readable top-to-bottom code 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: 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
@Scheduledruns a method on a timer; enabled with the@EnableSchedulingannotation.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 specifyzonefor cron.- By default all
@Scheduledtasks share one thread — for parallelism, set up aTaskSchedulerwith a pool. - Across several copies of the application a task fires on each — a shared lock via ShedLock leaves a single run.
@Asyncruns a method in the background; enabled with the@EnableAsyncannotation. The return type isvoid(no result) orCompletableFuture<T>(with a result and error).@Asyncpitfalls: a call viathisdoesn't work (you need a separate bean); an error from avoidmethod is lost (set anAsyncUncaughtExceptionHandler).- For
@Asyncunder load, set up a boundedThreadPoolTaskExecutorpool (taskExecutor). - Virtual threads (Java 21,
spring.threads.virtual.enabled=true) make concurrency cheap for I/O-bound tasks; they don't speed up computation.
What to read next
- Spring Events —
@Asynctogether with@EventListener. - Virtual threads in Java 21 — what is inside them and where they don't help.
- Spring WebFlux — a different approach to concurrent work.
@Transactionalin depth — the same proxy mechanism and the same pitfalls with calling viathis.