Skip to content

Async & Scheduled Tasks Interview Questions & Answers

16 questions Updated 2026-06-26 Share:

Spring Boot @Async and @Scheduled interview guide — enabling async, returning CompletableFuture, custom executors, why self-invocation breaks the proxy, fixedRate vs fixedDelay vs cron, exception handling, and graceful shutdown of task pools.

Read the in-depth guideSpring Boot @Async and @Scheduled: Off-Thread and On-Time Work(opens in new tab)
16 of 16

@Async tells Spring to run a method on a different thread so the caller returns immediately instead of blocking. Spring wraps the bean in a proxy that hands the invocation to a TaskExecutor; the method body runs on a pool thread while the caller continues. You must add @EnableAsync to a configuration class to activate the proxying — without it @Async is silently ignored.

@Configuration
@EnableAsync                      // required — without this @Async does nothing
class AsyncConfig { }

@Service
class EmailService {
    @Async                        // runs on a pool thread, caller returns at once
    void sendWelcome(String to) {
        // slow SMTP work happens off the request thread
    }
}

Rule of thumb: @Async only works on a public method called through the Spring proxy with @EnableAsync present — miss either and the call runs synchronously.

Three options. void for fire-and-forget — you get no result and no way to know it finished. CompletableFuture<T> (or the older Future<T>) when the caller needs the result or wants to know about failures; return it with CompletableFuture.completedFuture(...). Returning a plain T makes no sense because the method returns before the work is done.

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

With a CompletableFuture the caller can compose results (thenApply, allOf) and see exceptions; with void exceptions vanish into the executor.

Rule of thumb: Use void for true fire-and-forget, CompletableFuture<T> whenever you need the result or the error.

@Async works through a proxy that wraps the bean. When you call another method on this, you bypass the proxy entirely — the call never crosses the proxy boundary, so the async advice never runs and the method executes on the current thread. This is the same self-invocation limitation that bites @Transactional and @Cacheable.

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

Fix it by moving process() into a separate bean and injecting it, or by self-injecting the proxy.

Rule of thumb: Proxy-based annotations only fire on calls that enter the bean from outside — never on internal this. calls.

Historically Spring fell back to a SimpleAsyncTaskExecutor, which creates a brand-new thread for every task and never pools them — under load that spawns unbounded threads and can exhaust memory. Modern Spring Boot improves this (and on Java 21 can use virtual threads), but you should not rely on the default for production workloads.

@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;
}

Rule of thumb: Always define a bounded ThreadPoolTaskExecutor for @Async — never ship the unbounded default to production.

Pass the bean name to the annotation: @Async("reportExecutor"). Spring looks up that Executor bean and runs the task on it. Without a name, Spring uses the single Executor/TaskExecutor bean it finds, or the default. Naming executors lets you isolate workloads — slow report generation shouldn't starve quick notification sends.

@Async("reportExecutor")          // routed to this specific pool
CompletableFuture<Report> build(long id) { ... }

Define multiple ThreadPoolTaskExecutor beans and route different jobs to different pools so one slow task type can't drain the threads another type needs.

Rule of thumb: Give each independent workload its own named executor so they fail and queue independently.

It depends on the return type. For a method returning CompletableFuture/Future, the exception is captured in the future and surfaces when you call get()/join(). For a void method the exception has nowhere to go — it's logged but the caller never sees it. To catch those, register an AsyncUncaughtExceptionHandler.

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

Rule of thumb: Exceptions from void @Async methods are invisible unless you wire an AsyncUncaughtExceptionHandler; with a CompletableFuture they ride along in the future.

Add @EnableScheduling to a configuration class and annotate methods with @Scheduled. Spring registers them with a TaskScheduler and invokes them on a background thread on the cadence you specify. The method must take no arguments and typically returns void.

@Configuration
@EnableScheduling
class SchedulingConfig { }

@Component
class Cleanup {
    @Scheduled(fixedRate = 60000)     // every 60s
    void purgeTempFiles() { ... }
}

Rule of thumb: @Scheduled needs @EnableScheduling, a no-arg method, and the bean to be a Spring-managed component.

fixedRate measures from the start of one run to the start of the next — a new execution begins every N ms regardless of how long the previous one took (if it overruns, runs can queue or overlap). fixedDelay measures from the end of one run to the start of the next — it waits N ms after the task finishes, so slow runs push the next start later and executions never overlap.

@Scheduled(fixedRate = 10000)     // start every 10s, come what may
void poll() { ... }

@Scheduled(fixedDelay = 10000)    // 10s gap AFTER each run completes
void drain() { ... }

Rule of thumb: Want a steady frequency → fixedRate; want a guaranteed gap between runs (no overlap) → fixedDelay.

Use @Scheduled(cron = "...") with Spring's 6-field cron (second minute hour day-of-month month day-of-week) — note the leading seconds field, which standard Unix cron lacks. Cron is for calendar-based schedules ("3am every day", "every Monday") rather than fixed intervals.

@Scheduled(cron = "0 0 3 * * *")              // 03:00:00 every day
void nightlyReport() { ... }

@Scheduled(cron = "0 0 9 * * MON-FRI",
           zone = "America/New_York")          // 9am weekdays, explicit TZ
void marketOpen() { ... }

Always set the zone for calendar jobs so daylight-saving and server-timezone changes don't shift them.

Rule of thumb: Use cron for clock/calendar times, fixedRate/fixedDelay for intervals — and pin the zone on every cron.

Use a property placeholder in the annotation. @Scheduled supports cronExpression, fixedRateString, fixedDelayString, and initialDelayString so you can pull the value from configuration and tune it per environment.

@Scheduled(cron = "${reports.cron:0 0 3 * * *}")   // default + override via config
void nightlyReport() { ... }

Setting the cron to - disables the task entirely, which is handy for switching a job off in one environment without code changes.

Rule of thumb: Drive schedules from *String properties so ops can retune (or disable with -) without a redeploy.

By default Spring schedules everything on a single-threaded TaskScheduler. That means a slow or stuck job blocks every other scheduled task behind it — they run sequentially, not in parallel. If you have multiple independent schedules, configure a pool.

@Bean
ThreadPoolTaskScheduler taskScheduler() {
    var s = new ThreadPoolTaskScheduler();
    s.setPoolSize(5);                 // independent jobs run concurrently
    s.setThreadNamePrefix("sched-");
    return s;
}

Or set spring.task.scheduling.pool.size in properties.

Rule of thumb: The default scheduler is single-threaded — bump the pool size the moment you have more than one independent @Scheduled job.

The exception is logged and swallowed — the scheduler does not retry that occurrence, but future executions continue on schedule. The one risk is fixedDelay/fixedRate jobs: an exception still counts as "finished", so the cadence is preserved. Because there's no built-in retry, you should handle failures inside the task.

@Scheduled(fixedDelay = 30000)
void sync() {
    try {
        doSync();
    } catch (Exception e) {
        log.error("sync failed, will retry next tick", e);   // own your retries
    }
}

Rule of thumb: A throwing @Scheduled task skips this run, keeps the next — wrap risky work in try/catch if you need logging or retry.

@Async is event-driven: it runs work off the caller's thread in response to a request (send an email, generate a report on demand). @Scheduled is time-driven: it runs work on a clock or interval with no caller (nightly cleanup, periodic polling). One reacts to calls; the other reacts to time.

@Async      void onSignup(User u) { sendWelcome(u); }   // triggered by a request
@Scheduled(cron = "0 0 2 * * *")
            void rotateLogs() { }                       // triggered by the clock

Rule of thumb: Off-load a request → @Async; run on a schedule with no caller → @Scheduled.

Yes. By default a @Scheduled method runs on the scheduler's thread, so a long-running job ties up a scheduler thread. Adding @Async makes the scheduler merely trigger the task and hand the actual work to an executor pool, freeing the scheduler to fire other jobs on time.

@Async("jobExecutor")
@Scheduled(fixedRate = 5000)      // scheduler triggers; work runs on jobExecutor
void heavyPoll() { ... }

Use this when a scheduled task is slow and you don't want it monopolizing the (often single-threaded) scheduler.

Rule of thumb: Stack @Async on @Scheduled to keep a slow periodic job off the scheduler thread.

@Scheduled is per-JVM — every running instance fires the job independently. With three replicas behind a load balancer, your "nightly email" sends three times. Spring has no built-in cluster coordination, so you need a distributed lock (e.g. ShedLock) or an external scheduler/queue to ensure one instance wins each tick.

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

Rule of thumb: In a multi-instance deployment, guard every @Scheduled job with a distributed lock — otherwise it runs once per replica.

Tell the executor to wait for tasks to complete on shutdown instead of killing threads mid-task. Set setWaitForTasksToCompleteOnShutdown(true) and an awaitTerminationSeconds so the JVM gives running tasks a grace window. Spring Boot's graceful shutdown (server.shutdown=graceful) coordinates this with the web layer.

ex.setWaitForTasksToCompleteOnShutdown(true);
ex.setAwaitTerminationSeconds(30);     // let running tasks drain

Without this, a deploy or scale-down can abandon half-finished work — a half-sent batch, an uncommitted side effect.

Rule of thumb: Enable wait-for-completion + an await timeout on every task pool so deploys drain in-flight work instead of dropping it.

More ways to practice

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

Join our WhatsApp Channel