When an application receives a shutdown signal, the first fear is losing data. Someone is hitting the database at that moment, a transaction is open, and if the connection pool closes at the wrong time, the write may roll back while the external call has already gone through. Let's look at how Spring Boot handles this by default and where you can accidentally break what used to work on its own.
Why HikariCP is closed last
Imagine closing a restaurant: first you stop letting new guests in, you wait for everyone to finish eating, and only then do you turn off the lights and lock the doors. If you turn off the lights earlier, some guests will be left with unfinished food on their plates.
Spring Boot does roughly the same thing with its components when it receives SIGTERM:
Step 1 — stop the "entrances":
- Tomcat stops accepting new HTTP requests (waits for in-flight ones)
- KafkaListenerContainer stops fetching new messages
- TaskScheduler doesn't start new tasks, waits for the current one
- ThreadPoolTaskExecutor drains its queue (if configured)
Step 2 — close the context:
- Beans are destroyed in the reverse order of creation
- HikariDataSource.close() — very last
By the time HikariCP closes its connections, all transactions have already completed — either committed or rolled back. The pool closes connections that are already idle.
This is the default behavior, and it is correct. There's no need to touch it.
What happens to active transactions
If a transaction is open at the moment of SIGTERM, its fate depends on the context in which it runs.
HTTP request in a transaction
@PostMapping("/orders")
@Transactional
public OrderResponse create(@RequestBody CreateOrderRequest req) {
var order = orderRepository.save(req.toOrder());
paymentService.charge(order);
return OrderResponse.from(order);
}
On SIGTERM, Tomcat in graceful shutdown mode lets in-flight handlers complete until the timeout. If the method manages to return a response, the transaction is committed. If the timeout expires first, the transaction is rolled back, the client gets a 5xx error, but the database stays consistent.
Scheduled task in a transaction
@Scheduled(fixedDelay = 30_000)
@Transactional
public void processOutbox() {
var batch = outboxRepository.findUnpublished(50);
batch.forEach(this::publish);
}
On shutdown, TaskScheduler waits for the current iteration of the task to finish. The transaction completes normally — either committed or rolled back. The next iteration won't start.
Kafka listener in a transaction
@KafkaListener(topics = "orders.events")
@Transactional
public void onEvent(OrderEvent event) {
orderRepository.save(fromEvent(event));
}
KafkaListenerContainer waits for the current batch of messages to finish. After that the transaction completes and the container stops.
Background task via @Async
@Async
@Transactional
public void processInBackground(Long orderId) {
// long-running processing
}
This is the trickiest case. On shutdown, ThreadPoolTaskExecutor must be configured explicitly:
@Bean
TaskExecutor taskExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(20);
return executor;
}
Without these settings, the thread is interrupted immediately and the transaction rolls back. If awaitTermination expires before the task finishes, the thread gets interrupted and the transaction rolls back as well.
Liquibase and Flyway — only at startup
Sometimes a question comes up: do you need to do anything with migrations during application shutdown? No.
Liquibase and Flyway run only at startup: they read migration files, apply new scripts, close their connections, and do nothing else. They don't interfere on shutdown and require no maintenance.
If you're thinking about an "SQL script on shutdown" — that's the wrong approach. All schema changes are made through migrations at startup.
Common mistake: closing the pool yourself
Sometimes developers write code like this:
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class DataSourceCleaner {
private final DataSource dataSource;
DataSourceCleaner(DataSource dataSource) {
this.dataSource = dataSource;
}
@PreDestroy
public void cleanup() {
((HikariDataSource) dataSource).close(); // don't do this
}
}
The intent is clear: close the pool at the very end. But this breaks the behavior for three reasons.
First, Spring Boot already knows how and when to close the DataSource — duplication leads to a double close and an IllegalStateException.
Second, @Order(LOWEST_PRECEDENCE) doesn't guarantee that this component will be destroyed last — other components with the same order may end up later.
Third, if the pool closes before a scheduled task with an open transaction finishes, all database calls will fail with SQLException: HikariDataSource closed — and the transaction rolls back at the worst possible moment.
The right solution is to do nothing. Spring Boot will close the DataSource at the right moment without your help.
The same logic applies to:
dataSource.unwrap(HikariDataSource.class).close()in a shutdown hook- a homegrown
BeanDestructionAwarefor the DataSource - any
@PreDestroywith SQL write operations
In short
- Spring Boot closes HikariCP after all other components — by that point transactions have already completed.
- HTTP requests, scheduled tasks, and Kafka listeners have their own mechanisms for waiting on an active transaction.
@Asyncrequires explicit configuration:setWaitForTasksToCompleteOnShutdown(true)andsetAwaitTerminationSeconds.- Liquibase and Flyway run only at startup and do nothing on shutdown.
- Closing the connection pool manually via
@PreDestroyis a mistake: Spring will do it itself, at the right moment.
What to read next
- Scheduled tasks and @Async during shutdown — configuring TaskScheduler and ThreadPoolTaskExecutor.
- HTTP graceful drain — how Tomcat waits for in-flight requests.
- Kafka shutdown — stopping the listener container and handling batches.
- Idempotency on interruption — what to do if a transaction rolled back after all.