Skip to content

Spring Boot · Async & Messaging

Spring Boot @Async and @Scheduled: Off-Thread and On-Time Work

5 min read Updated 2026-06-26 Share:

Practice Async & Scheduled Tasks interview questions

Two ways to leave the request thread

Most Spring Boot work happens on the thread that handled the HTTP request: a controller calls a service, the service does its work, and the response goes back. But some work doesn't belong there. Sending a welcome email shouldn't make the user wait. A nightly cleanup has no request at all. Spring gives you two annotations for these cases — @Async to push work onto another thread in response to a call, and @Scheduled to run work on a clock with no caller. They look similar but solve opposite problems: one is event-driven, the other is time-driven.

@Async: returning before the work is done

Add @EnableAsync to a configuration class, annotate a method with @Async, and Spring wraps the bean in a proxy that hands the call to a TaskExecutor. The caller returns immediately; the method body runs on a pool thread:

@Configuration
@EnableAsync                      // without this, @Async is silently ignored
class AsyncConfig { }

@Service
class EmailService {
    @Async
    void sendWelcome(String to) {
        // slow SMTP work — happens off the request thread
    }
}

The two easy mistakes here are forgetting @EnableAsync (the annotation becomes a no-op) and self-invocation. Because @Async works through a proxy, calling the method from within the same class bypasses the proxy and runs synchronously:

@Service
class JobService {
    public void run() {
        process();          // ❌ self-call — proxy bypassed, runs on this thread
    }
    @Async public void process() { }
}

The fix is the same as for @Transactional: move the async method into a separate bean and inject it, so the call crosses the proxy boundary.

Getting results and errors back

A void @Async method is fire-and-forget — you can't tell when it finished, and any exception it throws disappears into the executor (unless you register an AsyncUncaughtExceptionHandler). When you need the result or want to handle failures, return a CompletableFuture:

@Async
CompletableFuture<Report> build(long id) {
    Report r = expensiveBuild(id);
    return CompletableFuture.completedFuture(r);   // caller can join / chain / see errors
}

Now the caller can compose results with thenApply or allOf, and exceptions ride along in the future instead of vanishing.

Don't ship the default executor

The classic gotcha: without a configured executor, older Spring fell back to a SimpleAsyncTaskExecutor that creates a new thread per task and never pools them. Under load that spawns unbounded threads. Always define a bounded ThreadPoolTaskExecutor, and route different workloads to different named pools so a slow job type can't starve a fast one:

@Bean
ThreadPoolTaskExecutor taskExecutor() {
    var ex = new ThreadPoolTaskExecutor();
    ex.setCorePoolSize(8);
    ex.setMaxPoolSize(16);
    ex.setQueueCapacity(100);     // bounded — back-pressure instead of OOM
    ex.setThreadNamePrefix("async-");
    ex.initialize();
    return ex;
}

Then @Async("taskExecutor") targets it by name. Give each independent workload its own pool.

@Scheduled: running on the clock

Add @EnableScheduling, annotate a no-arg method with @Scheduled, and Spring runs it on a background thread. The three timing styles trip people up:

@Scheduled(fixedRate = 10000)     // start a run every 10s, regardless of duration
void poll() { ... }

@Scheduled(fixedDelay = 10000)    // wait 10s AFTER each run finishes — no overlap
void drain() { ... }

@Scheduled(cron = "0 0 3 * * *",  // 03:00:00 every day — calendar-based
           zone = "America/New_York")
void nightlyReport() { ... }

fixedRate gives you a steady frequency (runs can overlap or queue if they overrun). fixedDelay guarantees a gap between runs, so executions never overlap. cron is for clock and calendar times — note Spring's cron has six fields (the leading one is seconds), and you should always pin the zone so daylight-saving doesn't shift your jobs. Make schedules configurable by reading them from properties:

@Scheduled(cron = "${reports.cron:0 0 3 * * *}")   // override per environment; "-" disables
void report() { ... }

The single-thread scheduler trap

By default Spring runs all scheduled tasks on a single thread. One slow or stuck job blocks every other schedule behind it. The moment you have more than one independent @Scheduled method, configure a pool — either a ThreadPoolTaskScheduler bean or spring.task.scheduling.pool.size. And if a scheduled method throws, the exception is logged and swallowed: that run is skipped but future runs continue, so wrap risky work in try/catch if you need logging or your own retry.

The multi-instance gotcha

@Scheduled is per-JVM. Run three replicas behind a load balancer and your "nightly email" sends three times — once per instance. Spring has no built-in cluster coordination, so guard cluster-wide jobs with a distributed lock like ShedLock so exactly one node wins each tick:

@Scheduled(cron = "0 0 3 * * *")
@SchedulingLock(name = "nightlyReport")   // only one instance runs it
void nightlyReport() { ... }

Finally, let in-flight tasks finish during a deploy. Tell your executors to drain rather than drop work:

ex.setWaitForTasksToCompleteOnShutdown(true);
ex.setAwaitTerminationSeconds(30);

Combined with server.shutdown=graceful, this means a scale-down or redeploy finishes the half-sent batch instead of abandoning it.

Rule of thumb: Off-load a request with @Async and a bounded named executor; run on the clock with @Scheduled plus a thread pool and — in a cluster — a distributed lock.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel