Scheduling, Async Work, and Batch Processing

Scheduled, asynchronous, and batch work all move code outside the request-response path, but they solve different problems. A scheduled task says, “run this operation at a time or interval.” An asynchronous method says, “let the caller continue while this operation finishes on another thread.” A batch job says, “process a large or restartable workload in controlled chunks.” In Spring Boot they are often combined: a scheduler starts a nightly import, async methods handle slow notifications, and Spring Batch records reads, writes, skips, and retries.

By the end, you should be able to choose between @Scheduled, @Async, and Spring Batch; configure their executors; reason about transactions and retries; and diagnose failures outside the HTTP request thread.

How Spring Runs Background Work

Spring’s scheduling and async features are enabled by bean post-processors. When you add @EnableScheduling, Spring scans beans for methods annotated with @Scheduled and registers runnable tasks with a TaskScheduler. A scheduler thread wakes up according to a fixed rate, fixed delay, initial delay, or cron expression and invokes the target method. Scheduled methods should usually take no arguments and return void or an ignored value.

@EnableAsync works differently. It creates a proxy around beans that contain @Async methods. When another bean calls the proxied method, the proxy submits the invocation to an Executor and returns immediately. The method can return void, CompletableFuture<T>, or another supported future type. A direct self-call such as this.sendEmail() bypasses the proxy, so it runs synchronously. That proxy detail causes many async bugs.

Spring Batch is a larger framework. It persists job metadata in tables such as job instances, executions, step executions, and execution contexts. A Job contains one or more Step objects. A chunk-oriented step repeatedly reads items with an ItemReader, optionally transforms them with an ItemProcessor, and writes a chunk with an ItemWriter inside a transaction. If the process stops, Spring Batch can use its repository metadata to restart from the last committed chunk rather than starting blind.

API and Configuration Anatomy

The key annotations are small, but their runtime behavior depends on infrastructure beans. @Scheduled(fixedRate = 60000) measures the next start time from the previous start time. @Scheduled(fixedDelay = 60000) waits until a run finishes, then delays before the next start. @Scheduled(cron = "0 0 2 * * *", zone = "UTC") uses calendar time and should name a zone when business rules depend on a clock. A TaskScheduler controls how many scheduled methods can run at once.

@Async("executorName") submits work to a named executor. The executor’s core size, max size, queue capacity, thread name prefix, and rejection policy are part of the design, not tuning trivia. An unbounded queue hides overload until memory or latency fails; a tiny queue reveals it quickly but forces callers to handle rejection.

Batch configuration revolves around jobs, steps, readers, processors, writers, job parameters, and the job repository. Job parameters identify a job instance. Running the same job with the same identifying parameters is not a new instance; it is either a duplicate, restart, or already-complete execution depending on prior metadata. This is why a scheduled batch launch usually passes a business date, source file name, or generated run id deliberately.

Example 1: A Fixed-Delay Cleanup Task

The smallest useful scheduled task is maintenance work that can be repeated safely. This example deletes expired verification tokens every five minutes after the previous run completes. Fixed delay is appropriate because cleanup duration can vary; the application should not start overlapping cleanup runs merely because the previous database delete was slow.

import java.time.Instant;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
class VerificationTokenCleanup {
    private final VerificationTokenRepository tokens;

    VerificationTokenCleanup(VerificationTokenRepository tokens) {
        this.tokens = tokens;
    }

    @Scheduled(fixedDelayString = "PT5M", initialDelayString = "PT30S")
    @Transactional
    void deleteExpiredTokens() {
        int deleted = tokens.deleteByExpiresAtBefore(Instant.now());
        System.out.println("expiredTokensDeleted=" + deleted);
    }
}

interface VerificationTokenRepository {
    int deleteByExpiresAtBefore(Instant cutoff);
}

Expected behavior is one cleanup run about thirty seconds after startup, then another five minutes after each prior run finishes. If the first run deletes 42 rows, the deterministic part of the output is expiredTokensDeleted=42; the next run should normally delete fewer rows because the operation is idempotent with respect to already-deleted tokens.

Example 2: Async Email with a Bounded Executor

Async work is useful when the user-facing transaction should not wait for a slow integration. The important boundary is that the async method runs in a different thread, outside the caller’s stack. It should receive the data it needs as arguments, load its own state if necessary, and handle its own failure reporting.

import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;

@Configuration
@EnableAsync
class AsyncMailConfiguration {
    @Bean
    Executor mailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("mail-");
        executor.initialize();
        return executor;
    }
}

@Service
class ReceiptMailer {
    private final MailGateway mailGateway;

    ReceiptMailer(MailGateway mailGateway) {
        this.mailGateway = mailGateway;
    }

    @Async("mailExecutor")
    public void sendReceipt(String emailAddress, long orderId) {
        mailGateway.send(emailAddress, "Receipt for order " + orderId);
    }
}

interface MailGateway {
    void send(String to, String body);
}

When another bean calls receiptMailer.sendReceipt("alex@example.com", 1007L), the caller returns after the method is submitted to mailExecutor. The email send uses a thread named with the mail- prefix. If the mail provider is down and MailGateway throws, the exception is not thrown back to the original HTTP controller. For void async methods, configure an AsyncUncaughtExceptionHandler or record failures inside the method if operators need to retry them.

Example 3: A Chunk-Oriented Batch Import

Batch processing is the right tool when a workload is large enough to need restart metadata, chunk commits, skip rules, or operational history. This example imports customer rows in chunks of 100. Each chunk is read, processed, and written in one transaction. If row 350 fails after three successful chunks, the first 300 rows remain committed and the failed execution metadata explains where the job stopped.

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
class CustomerImportJobConfiguration {
    @Bean
    Job customerImportJob(JobRepository jobRepository, Step importCustomersStep) {
        return new JobBuilder("customerImportJob", jobRepository)
                .start(importCustomersStep)
                .build();
    }

    @Bean
    Step importCustomersStep(
            JobRepository jobRepository,
            PlatformTransactionManager transactionManager,
            ItemReader<CustomerRow> reader,
            ItemProcessor<CustomerRow, Customer> processor,
            ItemWriter<Customer> writer) {
        return new StepBuilder("importCustomersStep", jobRepository)
                .<CustomerRow, Customer>chunk(100, transactionManager)
                .reader(reader)
                .processor(processor)
                .writer(writer)
                .build();
    }
}

record CustomerRow(String email, String displayName) {}
record Customer(String email, String displayName) {}

The expected behavior is chunk-level durability: rows 1 through 100 commit together, then 101 through 200, and so on. If processing row 350 throws an exception, rows 301 through 349 roll back with that chunk while rows 1 through 300 remain visible. On restart, a restartable reader can resume from the saved execution context instead of reprocessing committed chunks.

Combining Scheduling and Batch

A common Spring Boot pattern is to use scheduling only as the trigger and Spring Batch as the workload engine. The scheduled method launches a job with explicit parameters, then exits. The batch job owns reading, transactions, restart behavior, and history. This separation keeps the scheduler from becoming a hidden job framework.

import java.time.LocalDate;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
class NightlyCustomerImportLauncher {
    private final JobLauncher jobLauncher;
    private final Job customerImportJob;

    NightlyCustomerImportLauncher(JobLauncher jobLauncher, Job customerImportJob) {
        this.jobLauncher = jobLauncher;
        this.customerImportJob = customerImportJob;
    }

    @Scheduled(cron = "0 15 2 * * *", zone = "UTC")
    void launchNightlyImport() throws Exception {
        JobParameters parameters = new JobParametersBuilder()
                .addLocalDate("businessDate", LocalDate.now())
                .toJobParameters();
        jobLauncher.run(customerImportJob, parameters);
    }
}

This launches once per day at 02:15 UTC. The businessDate parameter identifies the job instance. If the same completed instance is launched again, Spring Batch will reject it rather than silently importing the same business date twice. If reruns must be allowed, add a deliberate non-identifying parameter or use a new business key; do not accidentally defeat duplicate protection.

Design Choices and Trade-Offs

Use @Scheduled for small, local, repeatable tasks where one application instance running the task is acceptable or where duplicate runs are harmless. In a multi-instance deployment, each JVM has its own scheduler. If only one node may run a task, use a database lock, clustered scheduler, or external trigger.

Use @Async for short-lived background work that belongs to the same service but should not block the caller. Avoid using async as a substitute for a durable queue when the work must survive process shutdown. If the call is accepted only into memory, a crash can lose it unless the app first stores an outbox record or uses a message broker.

Use Spring Batch when the workload needs item-level accounting, restartability, chunk transactions, skip/retry policies, or operator visibility. Batch costs more to set up than a scheduled loop, but it provides a vocabulary for long-running work otherwise reinvented in application code.

Failure Modes and Troubleshooting

Scheduled method never runs. The symptom is no log line, no database update, and no breakpoint hit. The usual causes are missing @EnableScheduling, a method that is not on a Spring bean, an invalid cron expression, or tests that do not start scheduling. Confirm that the bean appears in the application context, add a startup log in the configuration class, and temporarily use a short fixed delay. Correct it by enabling scheduling and keeping the method on a managed bean.

Async method runs synchronously. The symptom is that the controller waits for the slow operation, or logs show the same request thread entering the async method. The common cause is self-invocation or a non-public method that the proxy cannot intercept. Check the thread name and call path. Correct it by moving the async method to another bean and calling its public proxied method.

Batch job says it is already complete. The symptom is a launch exception when the schedule fires again. The cause is reuse of the same identifying job parameters for a completed job instance. Inspect the job parameters in the batch metadata tables or startup logs. Correct it by choosing parameters that match the business meaning: the same business date should usually be protected from duplicate import, while an ad hoc replay should use an intentional replay parameter and documented cleanup rules.

Database pool exhaustion during background work. The symptom is web requests timing out while scheduled, async, or batch threads are active. The cause is often executor concurrency greater than the database connection pool can support. Compare thread pool sizes, chunk concurrency, and connection pool metrics. Correct it by bounding executor sizes, reducing parallel steps, shortening transactions, or increasing the pool only when the database can handle the added load.

Security, Performance, and Reliability

Background code still runs with authority. Do not assume there is a request user, locale, trace id, or security context available on worker threads. Pass only the identifiers needed for the work, reload authoritative state, and avoid logging full payloads from imports or emails. For scheduled jobs that call external systems, store credentials in normal Spring configuration sources rather than source code and rotate them without changing job logic.

Performance depends on back pressure: bounded executors, realistic queues, and measured chunk sizes make overload visible. For batch imports, larger chunks reduce transaction overhead but increase rollback cost and memory pressure. Smaller chunks commit progress frequently but can spend more time on database round trips. Reliability depends on idempotent writes, stable job parameters, and a way to disable or pause a background path during incident response.

Hands-On Lab

Prerequisites: a Spring Boot project with scheduling enabled, a database for Spring Batch metadata if you run the batch portion, and a test mail gateway or stub. Keep the lab in a local profile so real customers are not contacted.

  1. Add @EnableScheduling and create a scheduled cleanup bean that deletes expired rows using fixedDelayString = "PT1M".
  2. Add @EnableAsync and a named ThreadPoolTaskExecutor with a small queue. Create an async notification method and log the current thread name inside it.
  3. Create a Spring Batch job with one chunk-oriented step. Use a reader over a small CSV or test list, a processor that trims and validates email addresses, and a writer that stores accepted customers.
  4. Launch the batch job from a scheduled method with a businessDate parameter. Run it once, then run it again with the same parameter and observe duplicate-instance protection.
  5. Change one input row to be invalid and run with a new business date. Verify whether the whole chunk rolls back or whether your configured skip policy allows the job to continue.

Verification: confirm that scheduled logs appear after startup, async logs use the executor thread prefix rather than the request thread, batch metadata records a completed execution, and the customer table contains the rows expected for committed chunks. Cleanup: disable the local schedule, remove test rows, and clear only the lab job metadata if you need to rerun the same parameters.

Assessment Exercises

  1. A nightly import takes twelve minutes but is scheduled with a five-minute fixed rate. Explain what can happen and redesign the trigger to avoid overlapping work.
  2. An async receipt sender sometimes loses emails during deploys. Identify why @Async alone cannot guarantee delivery and sketch an outbox-based correction.
  3. A batch import fails on row 245 with chunk size 100. Which rows are committed, which are rolled back, and what metadata would you inspect before restart?
  4. Your service has a database pool of 10 connections, an async executor max size of 20, and a batch step using 5 concurrent workers. Explain the likely symptom under load and propose limits.
  5. Choose job parameters for importing a supplier file named customers-2026-09-06.csv. Which parameters should identify the job instance, and which should be non-identifying operational metadata?

Summary

Scheduling, async methods, and batch processing are three separate Spring tools for moving work out of the immediate request path. Scheduling supplies time-based triggers, async supplies executor-backed method dispatch, and Spring Batch supplies restartable, chunk-based workload management. In this Spring Boot course, the important skill is selecting the mechanism that matches the work: small repeatable maintenance tasks for scheduling, bounded best-effort background methods for async, and durable high-volume processing for batch. Configure executors and parameters deliberately, test failure paths, and make background work visible enough for operators to stop, restart, or repair it.