Background tasks are the most dangerous place during application shutdown. If you interrupt a task in the middle, the transaction rolls back, but the external call (HTTP, Kafka) has already gone out — and the data diverges. Let's look at how Spring manages three types of background tasks and what you need to configure.
How Spring stops @Scheduled tasks
By default, when it receives a SIGTERM signal, Spring simply stops the scheduler — without waiting for the current iteration. This is dangerous: the task is cut off midway.
The setting you need:
spring:
task:
scheduling:
shutdown:
await-termination: true
await-termination-period: 25s
pool:
size: 4
What happens with this configuration:
- Spring receives SIGTERM and publishes a context-closed event.
- The
ThreadPoolTaskSchedulerstops launching new iterations. - The current iteration runs to completion.
- If the iteration doesn't finish within 25 seconds, the scheduler interrupts the thread (
interrupt).
Example: an outbox relay sends events to Kafka every 500 ms:
@Component
@RequiredArgsConstructor
public class OutboxRelay {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, Object> kafkaTemplate;
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
var batch = outboxRepository.fetchUnpublished(50);
for (var event : batch) {
kafkaTemplate.send(event.topic(), event.partitionKey(), event.payload()).get();
outboxRepository.markPublished(event.id());
}
}
}
On SIGTERM while publish() is running:
- Spring waits for the current call to finish.
- If the transaction managed to commit, all events are marked as published.
- If not (for example, Kafka is unavailable), the transaction rolls back, the events stay unprocessed, and on the next start the relay picks them up again.
Why @Async is dangerous without explicit configuration
@Async uses a thread pool — and by default Spring does not wait for tasks in that pool to finish. When the application shuts down, any running @Async methods may be interrupted midway.
You need to explicitly declare an executor bean with the right settings:
@Bean(destroyMethod = "shutdown")
public ThreadPoolTaskExecutor asyncExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(20);
executor.initialize();
return executor;
}
The key parameters:
setWaitForTasksToCompleteOnShutdown(true)— wait for running tasks to finish.setAwaitTerminationSeconds(20)— the maximum wait time; after that, threads are interrupted.destroyMethod = "shutdown"— Spring will invoke a clean shutdown of the pool.
If an @Async method contains Thread.sleep or a blocking Future.get(), they throw InterruptedException on forced shutdown. The code must handle this:
@Component
@RequiredArgsConstructor
public class AsyncEmailSender {
private final EmailClient emailClient;
@Async
public CompletableFuture<Void> sendAsync(String to, String subject, String body) {
try {
emailClient.send(to, subject, body);
return CompletableFuture.completedFuture(null);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return CompletableFuture.failedFuture(e);
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
}
If an @Async method has a long chain of calls (payment → database → notification), it's better to move it into an outbox plus a separate worker. Then an interruption only means "we'll continue on the next run" rather than a lost result.
Outbox relay: why it's safe on shutdown
The outbox pattern pairs well with graceful shutdown precisely because of FOR UPDATE SKIP LOCKED:
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
var batch = dsl.selectFrom(OUTBOX_EVENT)
.where(OUTBOX_EVENT.PUBLISHED_AT.isNull())
.orderBy(OUTBOX_EVENT.ID)
.limit(50)
.forUpdate().skipLocked()
.fetch();
for (var row : batch) {
kafkaTemplate.send(row.getTopic(), row.getPartitionKey(), row.getPayload()).get();
row.setPublishedAt(Instant.now());
row.store();
}
}
On application shutdown:
- Spring waits for the current
publish()call to finish. - If the transaction committed, the rows are marked as published.
- If the transaction rolled back, the rows are left unlocked.
- On the next run (or in a neighboring pod),
SKIP LOCKEDgrabs those rows and processes them.
No event is lost or duplicated — provided that the Kafka send is idempotent or at-least-once is acceptable for the task at hand.
Common mistake: an infinite loop inside @Scheduled
// Wrong — Spring won't be able to stop this task
@Scheduled(fixedDelay = 100)
public void relay() {
while (true) {
publish();
}
}
TaskScheduler can stop between iterations, but it cannot interrupt an infinite loop within a single iteration — only if there's a blocking call inside that catches InterruptedException.
The right way: each iteration of the @Scheduled method processes one batch of events and finishes. The scheduler itself controls the frequency via fixedDelay.
What doesn't work and why
setWaitForTasksToCompleteOnShutdown(false) is the most dangerous setting. On shutdown, all running @Async tasks are killed immediately. A real data-loss scenario:
- The
@AsyncmethodchargeCustomeris executing. - The call to the payment system succeeded — money has been charged.
- Before the result is saved to the database — SIGTERM, the task is interrupted.
- There's no payment record in the database, but the money is already gone.
Idempotency helps with retries, but without waiting for completion, every shutdown is a potential data loss.
Other common mistakes:
await-termination-periodlarger than 30 seconds — it doesn't fit into the overall shutdown budget (60 seconds), leaving little time for HTTP draining and closing database connections.@Asyncwithout an explicitThreadPoolTaskExecutorbean — Spring uses a plainSimpleAsyncTaskExecutor, which does not support waiting for tasks to finish.- A custom thread pool without
destroyMethod = "shutdown"— the pool won't shut down cleanly, because Spring doesn't know about it.
In short
await-termination: true+await-termination-period: 25s— a mandatory setting for@Scheduled; without it the current iteration is interrupted.@Asyncrequires an explicitThreadPoolTaskExecutorbean withsetWaitForTasksToCompleteOnShutdown(true)andsetAwaitTerminationSeconds(20).destroyMethod = "shutdown"on the pool bean — otherwise Spring won't invoke a clean shutdown.- The outbox relay is safe on shutdown:
FOR UPDATE SKIP LOCKEDguarantees that an unfinished batch of events is picked up by the next run. - An infinite
while(true)inside@Scheduledblocks shutdown; each iteration must finish on its own. setWaitForTasksToCompleteOnShutdown(false)— data loss on any planned deployment.
Further reading
- The JVM and Spring: basic graceful shutdown configuration
- The database and transactions on shutdown
- HTTP draining: how not to drop in-flight requests
- Budgets and observability