SmartLifecycle, and each logs one line when the application shuts down: how many HTTP requests are still being served. A request that takes 1.5 seconds was in flight when the shutdown began. One bean printed requests still in flight = 1. The other printed requests still in flight = 0. The only difference between the two classes is a method that returns a number: getPhase(), which one class leaves at its default and the other sets to 1000.
That number is the whole story of shutdown order, and it is one of about a dozen hooks Spring gives you between a bean being constructed and being thrown away. This article prints every one of them, in order, from a real run of one bean that implements them all. It then takes the three places where the order surprises people: @PostConstruct running before a bean is proxied, a BeanPostProcessor declared the wrong way, and SmartLifecycle phases, including what a graceful shutdown really does to a request that is mid-flight, with and without virtual threads. Every code block links to a file in the core-beans module of a companion repository that compiles and runs, and every console block is quoted from a transcript that a test run wrote, not typed in by hand.
Versions. Spring Boot 4.1.1 and Spring Framework 7.0.9 (both poms were published to Maven Central on 20 August 2026), on Java 25 (LTS, Temurin 25.0.4.1). The callback-order run uses SpringApplication without a web server; the shutdown scenarios start a real Boot web application on a random port and send it an HTTP request that is still running when the context is closed.
Every callback for one bean, printed in the order they ran
A Spring bean’s life has a start (the container builds it), a middle (it serves the application) and an end (the container closes). Along the way Spring will call any of about a dozen methods on it, if it has them. Tutorials list them; they rarely show them, and the order is the part that matters. So the repository has a class that implements every hook and logs each one. Nobody should write a class like this, and it says so in its Javadoc; it exists so the order can be printed (KitchenSink.java). Its declaration lists the interfaces:public class KitchenSink implements BeanNameAware, BeanFactoryAware, ApplicationContextAware,
InitializingBean, DisposableBean, SmartInitializingSingleton, SmartLifecycle {
An annotated method and an interface method follow in the same file; each does nothing except log its own name:
@PostConstruct
void postConstruct() {
Trace.log("@PostConstruct");
}
@Override
public void afterPropertiesSet() {
Trace.log("InitializingBean.afterPropertiesSet");
}
The class is registered by a @Bean method that also names an init method and a destroy method, next to a post-processor that logs the two BeanPostProcessor hooks for this one bean (LifecycleConfig.java):
@Configuration
public class LifecycleConfig {
@Bean
static TracingPostProcessor tracingPostProcessor() {
return new TracingPostProcessor();
}
@Bean
Dependency dependency() {
return new Dependency();
}
@Bean(initMethod = "customInit", destroyMethod = "customDestroy")
KitchenSink kitchenSink() {
return new KitchenSink();
}
}
The test starts it with SpringApplication, so application events fire too, and closes the context. 07-full-callback-order.txt is the transcript. First the start-up half:
constructor
setter injection (@Autowired setDependency)
BeanNameAware.setBeanName("kitchenSink")
BeanFactoryAware.setBeanFactory
ApplicationContextAware.setApplicationContext
BeanPostProcessor.postProcessBeforeInitialization
@PostConstruct
InitializingBean.afterPropertiesSet
@Bean(initMethod = "customInit")
BeanPostProcessor.postProcessAfterInitialization
SmartInitializingSingleton.afterSingletonsInstantiated
SmartLifecycle.start (phase 2147483647)
ContextRefreshedEvent
ApplicationStartedEvent
ApplicationRunner.run
ApplicationReadyEvent
BeanNameAware and friends) come before any initialisation hook: the bean is told its name and its container before it is asked to initialise itself. Second, @PostConstruct, afterPropertiesSet() and the @Bean init method run in exactly that order, and all three sit between the post-processor’s before and after calls. Third, the hooks that look like the bean being “ready” do not come until later, and section 2 explains why that matters.
Going deeper: what exactly runs @PostConstruct
Annotations do not run themselves. Something in the container has to notice @PostConstruct and call the method, and that something is a BeanPostProcessor named CommonAnnotationBeanPostProcessor, described on the reference page for @PostConstruct and @PreDestroy. It shows up in the list of registered post-processors printed in section 4, at position 5, after the demo’s own post-processor at position 4. That is consistent with the transcript above, where the demo post-processor’s before line comes first.
The practical consequence: @PostConstruct only works when that post-processor is registered. The listing in section 4 shows it registered in a Spring Boot context. A bare DefaultListableBeanFactory that you assemble by hand does not register it unless you add it; that last point is from the documentation, and the repository does not exercise it.
Going deeper
- Reference: Spring Framework – Customizing the Nature of a Bean (lifecycle callbacks, aware interfaces)
- Reference: Spring Framework – @PostConstruct and @PreDestroy
- Source: LifecycleTest.java (
fullOrderUnderSpringBootwrites the transcript) - Previous in this series: constructor, setter and field injection, which explains why the constructor runs before the setter and field injection you see at the top of the list
@PostConstruct means “this bean is wired”, not “the application is ready”
The most common mistake with lifecycle hooks is putting work in @PostConstruct that needs the rest of the application. Look at where @PostConstruct sits in the transcript above, and at everything that comes after it: afterSingletonsInstantiated, SmartLifecycle.start, the context-refreshed event, the started event, the application runner, the ready event. When the first of them runs, this bean is initialised, but other beans may not exist yet, the web server may not be listening, and the application has not started.
That is by design. @PostConstruct is for the bean’s own set-up: check that required settings are present, compute something derived from the injected dependencies, open a resource the bean itself owns. The other hooks exist so that later work can wait for later conditions:
| If the work needs… | Use | Why |
|---|---|---|
| only this bean and its injected dependencies | @PostConstruct | runs once, right after injection, before the bean is handed to anyone |
| every ordinary singleton to exist | SmartInitializingSingleton | runs after the container has instantiated all non-lazy singletons |
| to start and stop background work in a chosen order | SmartLifecycle | started and stopped by phase (sections 6 to 8) |
| a one-off task once the application has started | ApplicationRunner, or ApplicationReadyEvent | run after the context is refreshed; measured under SpringApplication only |
@PostConstruct does. A bean whose method throws IllegalStateException("cache warm-up failed") (FailingInit.java):
public class FailingInit {
@PostConstruct
void warmUp() {
throw new IllegalStateException("cache warm-up failed");
}
}
does not log an error and carry on. It stops the application from starting. 10-postconstruct-failure.txt:
context started: false
top exception : org.springframework.beans.factory.BeanCreationException
top message : Error creating bean with name 'failingInit': Invocation of init method failed
root cause : java.lang.IllegalStateException: cache warm-up failed
The exception you see first is a BeanCreationException saying the init method failed. The line that names your mistake is the root cause at the bottom of the chain. When a Boot application refuses to start and the top-level message looks generic, read down to root cause before anything else.
Doing slow or fallible work in @PostConstruct makes start-up slow and fragile. A network call to a service that is down turns into an application that will not start. Sometimes that is exactly what you want (fail fast on a missing configuration value), and sometimes it is a rolling deployment that cannot complete. Decide on purpose, not by default.
Going deeper
- Reference: Spring Framework – Initialization Callbacks
- Source: LifecycleTest.java (
failingPostConstructStopsStartup) - Related on this site: Spring Boot 4 on Kubernetes: probes and graceful shutdown for how start-up and readiness relate in a deployment
@PostConstruct runs before the proxy exists
Some beans are wrapped: Spring puts a stand-in object, a proxy, in front of them so it can add behaviour around each method call. @Async, @Transactional and @Cacheable all work this way. The proxy is made after initialisation is finished, and what other beans are given is the proxy, not the original.
The consequence is that inside @PostConstruct, this is the original, unwrapped object. This bean has one method marked @Async and logs what this is when @PostConstruct runs (AsyncMailer.java):
public class AsyncMailer {
@PostConstruct
void init() {
Trace.log("@PostConstruct sees this = " + getClass().getName() + ", isAopProxy(this) = "
+ AopUtils.isAopProxy(this));
}
@Async
public void send() {
}
@org.springframework.context.annotation.Configuration
@EnableAsync
public static class Config {
}
}
The test then fetches the same bean from the context and prints what it got. 09-postconstruct-before-proxy.txt:
@PostConstruct sees this = com.ankurm.corebeans.lifecycle.AsyncMailer, isAopProxy(this) = false
the bean other code receives : com.ankurm.corebeans.lifecycle.AsyncMailer$$SpringCGLIB$$0
@PostConstruct is the plain AsyncMailer; the object in the context is a generated subclass. A call to this.send() from inside @PostConstruct is therefore a direct call on the raw object. It skips the advice, so it would run on the calling thread, not asynchronously. The same goes for @Transactional or @Cacheable methods called from an init method.
The fix is not to call proxied methods from init code. If start-up work needs a transaction, an async call or a cache, do it from a hook that runs after the container has finished building beans (SmartInitializingSingleton,ApplicationRunner, or aSmartLifecycle.start) and call it through another bean, so that the call goes through the proxy. This is the same proxy-bypass as the self-invocation trap described in the AOP and transaction articles linked below; init code is just a place people meet it without a second bean in sight.
Going deeper
- Related on this site: Spring AOP: pointcuts, advice types and why your aspect is not firing (self-invocation and proxies)
- Related on this site: @Transactional: propagation, isolation and silent failures
- Related on this site: Spring Boot 4 @Async, executors and virtual threads
- Source: LifecycleTest.java (
postConstructRunsOnTheRawTarget)
A BeanPostProcessor is a bean that edits other beans, so declare it static
A BeanPostProcessor gets two calls for every bean the container creates: one just before the initialisation hooks and one just after them. It can inspect the bean, change it, or return a different object entirely, which is how proxies are made. Spring’s own annotation support is built out of them. A Boot context of the plain kind, with one extra post-processor of ours, has these registered, in the order they run (19-bean-post-processors.txt):
1 org.springframework.context.support.ApplicationContextAwareProcessor
2 org.springframework.context.annotation.ConfigurationClassPostProcessor$ImportAwareBeanPostProcessor
3 org.springframework.context.support.PostProcessorRegistrationDelegate$BeanPostProcessorChecker
4 com.ankurm.corebeans.lifecycle.TracingPostProcessor
5 org.springframework.context.annotation.CommonAnnotationBeanPostProcessor
6 org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor
7 org.springframework.context.support.ApplicationListenerDetector
Position 4 is the demo’s. It is small (TracingPostProcessor.java):
public class TracingPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if (beanName.equals("kitchenSink")) {
Trace.log("BeanPostProcessor.postProcessBeforeInitialization");
}
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (beanName.equals("kitchenSink")) {
Trace.log("BeanPostProcessor.postProcessAfterInitialization");
}
return bean;
}
}
Positions 5 and 6 are the ones that make @PostConstruct, @Resource and @Autowired work at all. Nothing about annotation-driven Spring is magic: it is post-processors reading annotations.
The trap is in how a post-processor is declared. Post-processors have to exist before ordinary beans are created, so that they can process them. If you declare one with an ordinary, non-static @Bean method, the container first has to build the configuration class that holds the method, and it has to do that too early for the class to be processed by every post-processor. Spring notices and warns. The configuration class (NonStaticProcessorConfig.java):
public class NonStaticProcessorConfig {
@Bean
BeanPostProcessor quietProcessor() {
return new BeanPostProcessor() {
};
}
@Bean
Dependency plainBean() {
return new Dependency();
}
}
logs this on start-up (08-non-static-bpp-warning.txt, one long line, quoted whole):
2026-09-24T10:28:59.979+05:30 WARN 3358 --- [core-beans] [ main] trationDelegate$BeanPostProcessorChecker : Bean 'nonStaticProcessorConfig' of type [com.ankurm.corebeans.lifecycle.NonStaticProcessorConfig$$SpringCGLIB$$0] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). The currently created BeanPostProcessor [quietProcessor] is declared through a non-static factory method on that class; consider declaring it as static instead.
The message names the class, says it is “not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)”, and tells you the fix: “consider declaring it as static instead”. The correct declaration is the one already shown in LifecycleConfig earlier, where tracingPostProcessor() is static. That one-word difference is the whole fix.
Read that WARN. It looks like noise. It means that the configuration class it names, and anything it produces, may miss advice that every other bean gets: proxies for@Transactional,@Asyncor security annotations. Symptoms appear far from the cause, as a method that silently runs without its transaction.
Going deeper
- Reference: Spring Framework – Container Extension Points (
BeanPostProcessor,BeanFactoryPostProcessor, and the ordering rules) - Related on this site: Spring AOP and why an aspect does not fire; most “not proxied” bugs are a bean that a post-processor never saw
- Source: LifecycleTest.java (
beanPostProcessorsInABootContext,nonStaticBeanPostProcessorWarns)
Shutdown mirrors start-up, and the event comes first
The second half of the same transcript is what happens when the context closes (07-full-callback-order.txt):=== ctx.close() ===
ContextClosedEvent
SmartLifecycle.stop
@PreDestroy
DisposableBean.destroy
@Bean(destroyMethod = "customDestroy")
Read it as a mirror. ContextClosedEvent is published first, while every bean is still fully alive, so a listener can still use the beans it needs. Then the SmartLifecycle beans are stopped. Only then do the destroy callbacks run, and they come in the reverse of the initialisation trio:
| At start-up | At shutdown |
|---|---|
@PostConstruct | @PreDestroy |
InitializingBean.afterPropertiesSet() | DisposableBean.destroy() |
@Bean(initMethod = …) | @Bean(destroyMethod = …) |
SmartLifecycle.start() | SmartLifecycle.stop() |
SmartLifecycle.stop comes before @PreDestroy. That is the property the rest of the article leans on: by the time a bean’s destroy method runs, everything that was started in a lifecycle phase has already been asked to stop, so @PreDestroy is the place to release resources, and stop() is the place to stop doing work.
Two things this transcript does not show. First, the test closes the context by calling close(). A deployed application closes because the process receives SIGTERM, and Spring Boot registers a JVM shutdown hook that calls the same code; that is documented on the Boot graceful shutdown page linked below and is not exercised here. Second, prototype beans never reach a destroy callback at all, because the container does not keep them; the scopes article measures that, with the transcript to prove it.
Going deeper: order across several beans
The demo has a single interesting bean, so the transcript shows the order of hooks, not the order of beans. Across beans, the documented rule is that Spring destroys a bean before the beans it depends on, the mirror image of creating dependencies first. That is what makes it safe for a service to use its repository inside its own @PreDestroy. The repository does not measure it; the phase-by-phase ordering of SmartLifecycle beans, which the next three sections measure, is the tool you use when you need an order that dependencies alone do not give you.
Going deeper
- Reference: Spring Framework – Destruction Callbacks
- Reference: Spring Boot – Graceful Shutdown (how the JVM shutdown hook and the timeout fit together)
- Previous in this series: Spring bean scopes
SmartLifecycle gives you an order for starting and stopping, and it is a number
Spring has a small interface for things that start and stop, Lifecycle (start(), stop(), isRunning()), and a richer one, SmartLifecycle, which adds a phase and starts itself automatically. Most beans that own a thread, a listener or a connection should implement the second.
The demo bean has a phase, a name and a stop duration, so the tests can vary them (PhasedWorker.java):
@Override
public int getPhase() {
return phase;
}
@Override
public void start() {
running.set(true);
Trace.log("start " + name + " (phase " + phase + ")");
}
/** Blocking variant: Spring's default stop(Runnable) calls this, then the callback, on its own thread. */
@Override
public void stop() {
sleep(stopMillis);
running.set(false);
Trace.log("stop " + name + " (phase " + phase + ")");
}
A test registers three of them with phases 300, 100 and 200, in that order, and closes the context (LifecycleTest.java):
void smartLifecyclePhases() {
try (var t = new Transcript("11-smartlifecycle-phases.txt",
"Three SmartLifecycle beans registered in the order 300, 100, 200")) {
var ctx = new org.springframework.context.annotation.AnnotationConfigApplicationContext();
ctx.registerBean("workerC", PhasedWorker.class, () -> new PhasedWorker("C", 300, 0, false));
ctx.registerBean("workerA", PhasedWorker.class, () -> new PhasedWorker("A", 100, 0, false));
ctx.registerBean("workerB", PhasedWorker.class, () -> new PhasedWorker("B", 200, 0, false));
ctx.refresh();
t.line("=== refresh() ===");
Trace.drain().forEach(e -> t.line("%s", e));
ctx.close();
t.line("=== close() ===");
var stops = Trace.drain();
stops.forEach(e -> t.line("%s", e));
assertThat(stops.get(0)).contains("phase 300");
assertThat(stops.get(2)).contains("phase 100");
}
}
11-smartlifecycle-phases.txt is what it printed:
=== refresh() ===
start A (phase 100)
start B (phase 200)
start C (phase 300)
=== close() ===
stop C (phase 300)
stop B (phase 200)
stop A (phase 100)
SmartLifecycle.DEFAULT_PHASE, which is Integer.MAX_VALUE, or 2147483647; the demo class from section 1 printed it in its start line, and 15-smartlifecycle-beans-in-boot.txt prints the constant. So a default-phase bean starts last and stops first. Section 8 shows why that matters.
Going deeper: plain Lifecycle is not started for you
The interface without Smart in its name does not start itself when the context refreshes. It waits for an explicit context.start(). The class (PlainLifecycle.java):
public class PlainLifecycle implements Lifecycle {
private boolean running;
@Override
public void start() {
running = true;
Trace.log("PlainLifecycle.start");
}
@Override
public void stop() {
running = false;
Trace.log("PlainLifecycle.stop");
}
@Override
public boolean isRunning() {
return running;
}
}
and what happened to it (13-plain-lifecycle.txt):
after refresh() : events=[] isRunning=false
after start() : events=[PlainLifecycle.start] isRunning=true
Right after refresh() it had received no events and was not running. Only the later start() call started it. If you implemented Lifecycle expecting it to start with the application, that is why nothing happened. The other half of SmartLifecycle’s convenience is that its isAutoStartup() returns true unless you override it, which is what the demo classes rely on.
Going deeper
- Reference: SmartLifecycle Javadoc (Framework 7.0.9) (phase rules,
isAutoStartup,stop(Runnable)) - Reference: Spring Framework – Startup and Shutdown Callbacks
- Source: PhasedWorker.java and LifecycleTest.java (
smartLifecyclePhases,plainLifecycleIsNotAutoStarted)
Beans in the same phase stop one after another unless stop(Runnable) returns at once
Everything with the same phase is stopped as a group, and the container waits for the whole group before moving to the next phase. How the group behaves depends on a detail of the interface. SmartLifecycle has two stop methods: stop(), and stop(Runnable callback), which is the one the container actually calls. The default version of the second calls the first and then runs the callback. So a bean that only overrides stop(), and takes a while to do it, makes the container wait, and its neighbours in the same phase wait behind it.
The demo worker can be told to override the callback version and finish on another thread (PhasedWorker.java):
public void stop(Runnable callback) {
if (!asyncStop) {
SmartLifecycle.super.stop(callback);
return;
}
Thread.ofVirtual().start(() -> {
sleep(stopMillis);
running.set(false);
Trace.log("stop " + name + " (phase " + phase + ", async callback)");
callback.run();
});
}
Three workers in the same phase, each needing 400 ms to stop, were closed twice, once with the blocking variant and once with this one. 12-blocking-vs-async-stop.txt:
stop(Runnable) blocks (the default) : close() took >= 1100 ms (sequential)
stop(Runnable) returns immediately : close() took < 800 ms (concurrent)
The blocking workers took at least 1100 ms in total, which is three times 400 ms plus overhead: one after another. The asynchronous ones took under 800 ms, which fits all three stopping at the same time. If you have several slow stoppers in one phase, this is the cheap win. It only helps if each of them really does call the callback when it is done.
A stop(Runnable) that never calls its callback makes shutdown wait for the full timeout. The container cannot tell that the bean has finished or is stuck.
The demo class for this ends a method with a comment where the call should be (NeverStops.java):
public void stop(Runnable callback) {
// forgot to call callback.run()
}
The container waits for the per-phase timeout and then carries on, and it says so in the log. With the timeout set to 500 ms, 14-shutdown-timeout.txt is:
close() returned after roughly the timeout: true
2026-09-24T10:28:58.263+05:30 INFO 3358 --- [core-beans] [ main] o.s.c.support.DefaultLifecycleProcessor : Shutdown phase 2147483647 ends with 1 bean still running after timeout of 500ms: [neverStops]
The log line names the phase, says how many beans were still running, and names them ([neverStops]). It is at INFO level, so it is easy to miss in a noisy shutdown. The timeout is a setting, and its default is 30 seconds (18-property-defaults.txt, read from the metadata inside the Boot jars):
spring.lifecycle.timeout-per-shutdown-phase type=java.time.Duration default=30s
spring.threads.virtual.enabled type=java.lang.Boolean default=False
server.shutdown type=org.springframework.boot.web.server.Shutdown default=graceful
spring.lifecycle.timeout-per-shutdown-phase is per phase, as its name says, so several slow phases add up. If the process runs somewhere that kills it after a fixed grace period, the sum of your phase timeouts is what has to fit inside that period; the Kubernetes article linked below is about that constraint.
Going deeper
- Reference: Spring Boot – Graceful Shutdown (the timeout property)
- Related on this site: Spring Boot 4 on Kubernetes: probes, graceful shutdown, CPU limits and HPA
- Source: NeverStops.java, LifecycleTest.java (
sameScopeBlockingVersusAsyncStop,shutdownPhaseTimeout)
Graceful shutdown is a SmartLifecycle too, and your phase decides whether the requests are finished
Now the opening of this article makes sense. Spring Boot’s web server is not special: it is started and stopped by SmartLifecycle beans with phases of their own. A test starts a Boot web application with the two demo workers and asks the context for every SmartLifecycle bean, sorted by phase (15-smartlifecycle-beans-in-boot.txt):
phase bean name class
2147483647 earlyWorker EarlyWorker
2147482623 webServerGracefulShutdown WebServerGracefulShutdownLifecycle
2147481599 webServerStartStop WebServerStartStopLifecycle
1073741823 applicationTaskExecutor ThreadPoolTaskExecutor
1000 lateWorker LateWorker
-2147483647 springBootLoggingLifecycle Lifecycle
SmartLifecycle.DEFAULT_PHASE = 2147483647
server.shutdown=graceful selects; the runs below are the evidence it behaves that way), and one that closes the server. A bean with the default phase is above both of them, so it stops while requests are still being served. A bean with a small phase is below both, so it stops after the last request has finished.
The two demo workers differ only in that line. EarlyWorker leaves the phase alone (EarlyWorker.java):
/** Leaves getPhase() at its default, which is Integer.MAX_VALUE. */
@Component
public class EarlyWorker implements SmartLifecycle {
and LateWorker sets it to 1000 (LateWorker.java):
/** Phase 1000: far below the web server's phases, so it stops after the server has drained. */
@Component
public class LateWorker implements SmartLifecycle {
private volatile boolean running;
@Override
public int getPhase() {
return 1000;
}
@Override
public void start() {
running = true;
}
@Override
public void stop() {
running = false;
Trace.log("LateWorker.stop (phase " + getPhase() + "): requests still in flight = "
+ SlowController.IN_FLIGHT.get());
}
@Override
public boolean isRunning() {
return running;
}
}
Each logs the number of /slow requests currently executing. That endpoint sleeps for as long as it is told, and it is diagnostic code that you would delete before shipping (SlowController.java):
@GetMapping("/slow")
public String slow(@RequestParam long ms) throws InterruptedException {
IN_FLIGHT.incrementAndGet();
try {
Thread.sleep(ms);
COMPLETED.incrementAndGet();
return "finished after " + ms + " ms, virtual thread = " + Thread.currentThread().isVirtual();
} finally {
IN_FLIGHT.decrementAndGet();
}
}
The test starts the application, sends GET /slow?ms=1500, waits until the handler is running, and closes the context while the request is still in flight. 17-worker-phase-vs-web-server.txt:
EarlyWorker.stop (phase 2147483647): requests still in flight = 1
LateWorker.stop (phase 1000): requests still in flight = 0
the client saw: HTTP 200: finished after 1500 ms, virtual thread = true
The default-phase worker stopped while one request was still running. The phase-1000 worker stopped after the web server had drained, and saw none. The client, meanwhile, got a normal response: HTTP 200, after the full 1500 ms, and the handler was on a virtual thread. So graceful shutdown did what it is meant to do, and the phase of your beans decides which side of that line they are on.
What does “graceful” buy, and do virtual threads change it? The same request was run three ways (16-graceful-shutdown-in-flight.txt):
settings the client saw close() blocked handler ran to the end
-------------------------------------------- ---------------------------------------------------- -------------------------------- ----------------------
server.shutdown=graceful, virtual threads HTTP 200: finished after 1500 ms, virtual thread = true >= 1 s (waited for the request) yes
server.shutdown=graceful, platform threads HTTP 200: finished after 1500 ms, virtual thread = false >= 1 s (waited for the request) yes
server.shutdown=immediate, virtual threads FAILED: connection dropped, no response >= 1 s (waited for the request) yes
With server.shutdown=graceful the client got the full response whether the handler ran on a virtual thread or on a platform thread. Those runs did not differ. With immediate, the client saw a dropped connection and no response, while the last column says the handler nevertheless ran to the end. In this run the handler was not cancelled when its client was cut off. It ran to the end, so whatever a real handler did after that point (a database write, a message sent) would have happened too, with nobody left to be told.
A note on that middle column. The text “>= 1 s (waited for the request)” is a generic label the test prints wheneverclose()took at least a second. It appears on theimmediaterow too, where the request was not waited for by the client. I did not investigate whyclose()still took that long underimmediate, and I would not read anything into it beyond “took at least a second”. The column that carries the finding is the second one.
Going deeper: what the test does, and what it does not prove
The helper that produces every row above starts a real web application on a random port, fires the request asynchronously, waits until the handler is running, then times close() (GracefulShutdownTest.java):
private static Result runAndClose(String shutdownMode, boolean virtualThreads) throws Exception {
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ShutdownApp.class)
.web(WebApplicationType.SERVLET)
.properties("server.port=0", "logging.level.root=OFF", "spring.main.banner-mode=off",
"server.shutdown=" + shutdownMode,
"spring.threads.virtual.enabled=" + virtualThreads,
"spring.lifecycle.timeout-per-shutdown-phase=10s")
.run();
int port = Integer.parseInt(ctx.getEnvironment().getProperty("local.server.port"));
HttpClient client = HttpClient.newHttpClient();
CompletableFuture<String> pending = client
.sendAsync(HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/slow?ms=1500")).build(),
HttpResponse.BodyHandlers.ofString())
.thenApply(r -> "HTTP " + r.statusCode() + ": " + r.body())
.exceptionally(e -> "FAILED: connection dropped, no response");
long deadline = System.currentTimeMillis() + 5000;
while (SlowController.IN_FLIGHT.get() == 0 && System.currentTimeMillis() < deadline) {
Thread.sleep(10);
}
assertThat(SlowController.IN_FLIGHT.get()).isEqualTo(1);
long begin = System.nanoTime();
ctx.close();
long closeMs = (System.nanoTime() - begin) / 1_000_000;
String response = pending.get();
int completed = SlowController.COMPLETED.get();
SlowController.COMPLETED.set(0);
return new Result(response, closeMs, completed);
}
Two limits. One request is one request: the runs show that graceful shutdown lets an in-flight request finish, not how many can be drained or how it behaves under load. And the timeout is set to 10 seconds here; a request that outlasts the timeout would be a fourth experiment, which the repository does not run. Section 7 shows the log line the container writes when a phase runs out of time.
Boot’s own applicationTaskExecutor, at phase 1073741823 in the list above, is the kind of executor the @Async article is about. It stops after the web server has drained and before a phase-1000 bean, which is worth knowing when a request hands work to it.
Going deeper
- Reference: Spring Boot – Graceful Shutdown
- Related on this site: Spring Boot 4 @Async, executors and virtual threads
- Related on this site: Spring Boot 4 on Kubernetes: probes and graceful shutdown
- Source: GracefulShutdownTest.java (
inFlightRequestDuringClose,workerPhasesAgainstTheWebServer)
Which hook, and when
The whole article in one table:| Hook | Runs when | Good for | Watch out for |
|---|---|---|---|
| constructor | first | assigning final fields | setter and field dependencies are not there yet (injection styles) |
@PostConstruct | after injection, before proxying | checking settings, deriving state from dependencies | this is the raw target; an exception stops start-up |
afterPropertiesSet() | right after @PostConstruct | library code that should not need annotation support | ties the class to Spring |
@Bean(initMethod) | after afterPropertiesSet() | a third-party class you cannot annotate | the name is a string, so the compiler cannot check it |
SmartInitializingSingleton | after all non-lazy singletons exist | work that needs other singletons complete | runs only for singletons created during start-up |
SmartLifecycle.start() | end of refresh, ascending phase | starting threads, listeners, consumers | default phase starts last and stops first |
ApplicationRunner, ApplicationReadyEvent | after the application started | one-off start-up tasks | measured under SpringApplication only |
@PreDestroy, destroy(), destroyMethod | on close, after lifecycle stop | releasing resources the bean owns | never called for prototypes; not called if the process is killed outright |
SmartLifecycle.stop() | on close, descending phase | stopping work in a chosen order | blocking stops in one phase run one after another; missing callback means a timeout |
Should you even care? For a typical application made of controllers, services and repositories, you may never write aSmartLifecycle. Boot’s defaults already drain in-flight web requests on shutdown, and the measured runs above show the client getting its answer. The parts most worth remembering are the cheap ones:@PostConstructis not “ready” and does not see the proxy, a non-staticBeanPostProcessoris a bug the container warns you about, and a default-phase lifecycle bean stops before the web server has finished its requests. If you own background threads, a queue consumer or a scheduled poller, choose a phase deliberately. Which number to choose is a judgment call: the runs show that 1000 falls after the web server drained and the default falls before, and nothing more.
Going deeper
- Previous in this series: Dependency injection: constructor, setter, field, @Autowired resolution and bean scopes
- Interview preparation: Top 50 Spring Boot 4 Interview Questions and Answers (2026); the lifecycle order is a standard question
Further reading
- Companion repository for this article: asmhatre/spring-boot-demo, core-beans module
- Other articles in this series: Constructor vs Setter vs Field injection, @Autowired Explained, Spring Bean Scopes
- On this site: Spring Boot 4 @Async, executors and virtual threads, Kubernetes probes and graceful shutdown, Spring AOP: why an aspect is not firing, @Transactional propagation and isolation
- Official reference: Spring Framework – Customizing the Nature of a Bean
- Official reference: Spring Framework – Container Extension Points
- Official reference: Spring Boot – Graceful Shutdown
- Official Javadoc: SmartLifecycle (Framework 7.0.9)
No Comments yet!