Skip to main content

Spring Bean Lifecycle in Boot 4: @PostConstruct, InitializingBean, SmartLifecycle and Shutdown Order

Every Spring bean lifecycle callback printed in order from a real run, @PostConstruct running before the proxy exists, the non-static BeanPostProcessor warning, SmartLifecycle phases measured, and a request in flight during graceful shutdown with virtual threads. Tested on Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 25.

Two beans were written to be identical. Each one implements 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
Create constructor → @Autowired setter Aware setBeanName → setBeanFactory → setApplicationContext Initialize BPP.before → @PostConstruct → afterPropertiesSet → initMethod → BPP.after Singletons ready afterSingletonsInstantiated Application start → Refreshed → Started → ApplicationRunner → Ready Shutdown Closed → stop → @PreDestroy → destroy → destroyMethod
The same list, grouped. Everything in the green lane happens once per bean and in a fixed order, right after the constructor and the injection of dependencies. The amber lane happens once per container, when every ordinary singleton exists. The blue lane is the application coming up as a whole. The red lane is the mirror image at the end, and section 5 reads it in detail. Three facts from the transcript are worth keeping in your head before going further. First, the aware callbacks (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

@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…UseWhy
only this bean and its injected dependencies@PostConstructruns once, right after injection, before the bean is handed to anyone
every ordinary singleton to existSmartInitializingSingletonruns after the container has instantiated all non-lazy singletons
to start and stop background work in a chosen orderSmartLifecyclestarted and stopped by phase (sections 6 to 8)
a one-off task once the application has startedApplicationRunner, or ApplicationReadyEventrun after the context is refreshed; measured under SpringApplication only
The second surprise is what a failure in @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

@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
Two objects exist for one bean AsyncMailer (raw target) constructed, injected AsyncMailer$$SpringCGLIB$$0 the proxy: adds @Async behaviour wrapped after init @PostConstruct runs here this = raw target, isAopProxy(this) = false Every other bean is handed this one. A call through it goes via the advice.
The figure and the transcript say the same thing. The object that ran @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 a SmartLifecycle.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

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, @Async or security annotations. Symptoms appear far from the cause, as a method that silently runs without its transaction.

Going deeper

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-upAt shutdown
@PostConstruct@PreDestroy
InitializingBean.afterPropertiesSet()DisposableBean.destroy()
@Bean(initMethod = …)@Bean(destroyMethod = …)
SmartLifecycle.start()SmartLifecycle.stop()
Notice that 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

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)
Start: lowest phase first A (phase 100) B (phase 200) C (phase 300) Stop: highest phase first C (phase 300) B (phase 200) A (phase 100) Registered in the order C, A, B. The phase decides, not the registration order.
The rule from the transcript: start in ascending phase order, stop in descending phase order. Registration order (C, A, B) made no difference. The consequence is that a bean with a low phase is a foundation: it starts early and stops late, so everything with a higher phase can rely on it. A bean with a high phase is at the edge: it starts last and stops first. What is the phase of a bean that does not say? 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

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

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
Stop order in a Boot web application (top stops first) 2147483647 earlyWorker default phase: 1 request in flight 2147482623 webServerGracefulShutdown stops taking new requests, drains 2147481599 webServerStartStop closes the server 1073741823 applicationTaskExecutor Boot’s default task executor 1000 lateWorker phase 1000: 0 requests in flight -2147483647 springBootLoggingLifecycle logging system, last of all stops first (top) → stops last (bottom)
Read the stack from the top, which is the order of stopping. Two beans sit just below the default phase and belong to the web server: one that stops accepting new requests and waits for the running ones (the Boot documentation describes this as what 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 whenever close() took at least a second. It appears on the immediate row too, where the request was not waited for by the client. I did not investigate why close() still took that long under immediate, 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

Which hook, and when

The whole article in one table:
HookRuns whenGood forWatch out for
constructorfirstassigning final fieldssetter and field dependencies are not there yet (injection styles)
@PostConstructafter injection, before proxyingchecking settings, deriving state from dependenciesthis is the raw target; an exception stops start-up
afterPropertiesSet()right after @PostConstructlibrary code that should not need annotation supportties the class to Spring
@Bean(initMethod)after afterPropertiesSet()a third-party class you cannot annotatethe name is a string, so the compiler cannot check it
SmartInitializingSingletonafter all non-lazy singletons existwork that needs other singletons completeruns only for singletons created during start-up
SmartLifecycle.start()end of refresh, ascending phasestarting threads, listeners, consumersdefault phase starts last and stops first
ApplicationRunner, ApplicationReadyEventafter the application startedone-off start-up tasksmeasured under SpringApplication only
@PreDestroy, destroy(), destroyMethodon close, after lifecycle stopreleasing resources the bean ownsnever called for prototypes; not called if the process is killed outright
SmartLifecycle.stop()on close, descending phasestopping work in a chosen orderblocking 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 a SmartLifecycle. 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: @PostConstruct is not “ready” and does not see the proxy, a non-static BeanPostProcessor is 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

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.