← Back to the section

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:

  1. Spring receives SIGTERM and publishes a context-closed event.
  2. The ThreadPoolTaskScheduler stops launching new iterations.
  3. The current iteration runs to completion.
  4. 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:

  1. Spring waits for the current publish() call to finish.
  2. If the transaction committed, the rows are marked as published.
  3. If the transaction rolled back, the rows are left unlocked.
  4. On the next run (or in a neighboring pod), SKIP LOCKED grabs 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:

  1. The @Async method chargeCustomer is executing.
  2. The call to the payment system succeeded — money has been charged.
  3. Before the result is saved to the database — SIGTERM, the task is interrupted.
  4. 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-period larger than 30 seconds — it doesn't fit into the overall shutdown budget (60 seconds), leaving little time for HTTP draining and closing database connections.
  • @Async without an explicit ThreadPoolTaskExecutor bean — Spring uses a plain SimpleAsyncTaskExecutor, 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.
  • @Async requires an explicit ThreadPoolTaskExecutor bean with setWaitForTasksToCompleteOnShutdown(true) and setAwaitTerminationSeconds(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 LOCKED guarantees that an unfinished batch of events is picked up by the next run.
  • An infinite while(true) inside @Scheduled blocks 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