CommandLineRunner, ApplicationRunner and a listener for ApplicationReadyEvent — and a fourth, SmartLifecycle, that people mention when the first three stop being enough.
This article tells you which one to pick, and then goes further than the usual answer. The usual answer is “the runners get different arguments”, which is true and is the least interesting difference. The differences that cost people days are about order (what runs before what, and what happens when you do not say), failure (a runner that throws takes the whole application down; so does a ready-event listener, after the application was already “ready”) and blocking (a slow runner keeps your readiness probe red while the port is already open).
Everything below was run, not remembered. The companion project is asmhatre/spring-boot-demo/startup-runners; its test suite writes the transcripts in output/ that every console block here is copied from, and the ordering claims were additionally checked against the Spring Boot sources and with javap. If you only want the answer: use CommandLineRunner when you want the raw arguments, ApplicationRunner when you want parsed --options, an ApplicationReadyEvent listener when you want to react to “the app has started” without arguments, and SmartLifecycle when the thing you start also has to be stopped. The rest of the page is why, with the traps.
Versions. Spring Boot 4.1.2 (the newest GA at the time of writing, on Maven Central since 2026-10-22; the<release>element in Maven Central’s metadata currently points at4.2.0-RC2, which is a release candidate, not a release), Spring Framework 7.0.10, JDK 25 (25.0.3), Maven 3.9. Ordering facts were read from the shipped sources and withjavap; the contrast with older Boot lines (3.0.13 and 3.1.12) is from their sources only — I did not run those.
All of them run after your beans exist — the difference is where, and what happens on failure
Picture startup as a short line of events. Spring creates your beans, finishes refreshing the application context, and starts any lifecycle beans. Spring Boot then announcesApplicationStartedEvent, calls every runner, and only then announces ApplicationReadyEvent and flips the application’s readiness to “accepting traffic”. Your three candidates sit at different points on that line.
- Spring Boot reference: SpringApplication (the official description of the runners and application events)
- output/15-callrunners-source.txt —
SpringApplication.run()reduced to the lines that call refresh, started, runners and ready
The smallest working example: one line from each hook
Here is a whole application with all three hooks, each printing one line. This is HelloApp.java (the two@Order lines are explained two sections down; ignore them for now):
@Bean
@Order(1)
CommandLineRunner commandLineRunner() {
return args -> System.out.println("hello: CommandLineRunner args = " + Arrays.toString(args));
}
@Bean
@Order(2)
ApplicationRunner applicationRunner() {
return args -> System.out.println("hello: ApplicationRunner options = " + args.getOptionNames()
+ ", non-option args = " + args.getNonOptionArgs());
}
@EventListener(ApplicationReadyEvent.class)
void onReady() {
System.out.println("hello: ApplicationReadyEvent listener");
}
Started with --greeting=hi --verbose notes.txt, it prints this, copied from output/00-hello.txt:
hello: CommandLineRunner args = [--greeting=hi, --verbose, notes.txt]
hello: ApplicationRunner options = [greeting, verbose], non-option args = [notes.txt]
hello: ApplicationReadyEvent listener
process exit code: 0
Three things to notice. Both runners were given the command line, but in different shapes: the CommandLineRunner got the arguments as the plain strings you typed, while the ApplicationRunner got an ApplicationArguments object that has already split them into options (--greeting=hi, --verbose) and non-option arguments (notes.txt). The ready listener got nothing, because it is an event listener, not a runner. And the listener printed last.
Going deeper:
- ApplicationRunner Javadoc
- Spring Application Events: @EventListener, @TransactionalEventListener and Async Events — how
@EventListenermethods (like the third hook) are dispatched
They always run in one fixed sequence — and readiness only flips at the very end
Do not trust a diagram, including the one above; print the order. The test behind output/01-startup-hooks-in-order.txt registers a listener or hook for every step and runs the application twice, once without a web server and once with Tomcat. Each runner and the ready listener also asksApplicationAvailability what state the application believes it is in. The second half, with the web server:
--- servlet web application (Tomcat on a random port) ---
@PostConstruct
InitializingBean.afterPropertiesSet
SmartInitializingSingleton.afterSingletonsInstantiated
WebServerInitializedEvent (port is open)
SmartLifecycle.start (default phase)
ContextRefreshedEvent
ApplicationStartedEvent
AvailabilityChangeEvent -> CORRECT
CommandLineRunner (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)
ApplicationRunner (readiness seen from inside: REFUSING_TRAFFIC)
ApplicationReadyEvent (readiness seen from inside: REFUSING_TRAFFIC)
AvailabilityChangeEvent -> ACCEPTING_TRAFFIC
Read it top to bottom. The three bean-level callbacks come first (the last section of this part shows why they are “too early”). Tomcat is already listening before ContextRefreshedEvent. Then ApplicationStartedEvent and the liveness state CORRECT, then both runners, then ApplicationReadyEvent, and only after that listener returns does readiness become ACCEPTING_TRAFFIC. So from inside a runner and from inside a ready listener, the application still reports REFUSING_TRAFFIC. If you expected “ready event means readiness is already UP”, that is the first surprise; the source explains it, because the run listener publishes the event first and the availability change second:
public void started(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
context.publishEvent(new ApplicationStartedEvent(this.application, this.args, context, timeTaken));
AvailabilityChangeEvent.publish(context, LivenessState.CORRECT);
}
public void ready(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
context.publishEvent(new ApplicationReadyEvent(this.application, this.args, context, timeTaken));
AvailabilityChangeEvent.publish(context, ReadinessState.ACCEPTING_TRAFFIC);
}
That is EventPublishingRunListener from the 4.1.2 sources, quoted from output/15-callrunners-source.txt.
Which one is “first”? Runners come before the ready listener, always. There is no setting that puts an ApplicationReadyEvent listener ahead of a runner, and no way to make a runner run after it. If you need “after everything, including all runners”, the ready event is that place. If you need “before the app is declared ready, in an order I control”, a runner is that place.
Why @PostConstruct, InitializingBean and ContextRefreshedEvent are the wrong place for startup work
They are not wrong tools; they answer a different question.
@PostConstruct and afterPropertiesSet() run while one bean is being built, which can be long before other beans exist and, in a web application, before the web server accepts a request. afterSingletonsInstantiated() runs once all singleton beans exist, still before the lifecycle beans start. In the transcript the web server opens its port after all three and before the default-phase SmartLifecycle.start.
ContextRefreshedEvent looks like “the app has started” and is the classic wrong choice: it fires before ApplicationStartedEvent, before any runner, and, according to the Spring documentation (I did not run this part), it is published again for a child context or an explicit refresh(), so a handler that does real work may do it more than once. Use it for Spring-level setup that must follow a refresh; use the Boot events for “the application has started”.
The probe class behind the transcript is LifecycleProbe.java; the event side is EventProbe.java.
Where the web server sits among SmartLifecycle beans
The web server is itself a
SmartLifecycle. Listing every lifecycle bean in a web application with its phase gives output/10-lifecycle-beans-in-a-web-app.txt:
phase -2147483647 springBootLoggingLifecycle Lifecycle
phase -100 low (this project)
phase 0 mid (this project)
phase 50 manual (this project)
phase 100 high (this project)
phase 200 slow (this project)
phase 1073741823 applicationTaskExecutor ThreadPoolTaskExecutor
phase 2147481599 webServerStartStop WebServerStartStopLifecycle
phase 2147482623 webServerGracefulShutdown WebServerGracefulShutdownLifecycle
SmartLifecycle.DEFAULT_PHASE = 2147483647
Integer.MAX_VALUE = 2147483647
timeout per shutdown phase = 30000 ms (spring.lifecycle.timeout-per-shutdown-phase, set by Boot)
Spring Boot 4.1.2, Spring Framework 7.0.10
webServerStartStop has phase 2147481599, which is Integer.MAX_VALUE minus 2048, and a bean that does not override getPhase() gets DEFAULT_PHASE, Integer.MAX_VALUE. Beans start in ascending phase order, so a default-phase bean starts after Tomcat is listening — which is exactly what the web transcript shows (WebServerInitializedEvent before SmartLifecycle.start (default phase)). The graceful-shutdown bean sits 1024 below MAX_VALUE, so on shutdown (descending order) a default-phase bean is stopped first, then graceful shutdown begins, then the server closes. I derived that stop order from the printed phases and the rule demonstrated in the SmartLifecycle section; I did not trace the web server’s own stop separately. The default spring.lifecycle.timeout-per-shutdown-phase is 30 s, set by Boot (Spring Framework’s own default is 10 s).
- Spring bean lifecycle: @PostConstruct, SmartLifecycle and shutdown order — the bean-level callbacks in depth
- Runners.java — the two runners that print the readiness state they see
CommandLineRunner gets raw strings; ApplicationRunner gets them parsed
This is the difference everybody lists, so here it is once, with the edge cases. The test behind output/04-arguments.txt passes one deliberately awkward command line — an option given twice, an option with no value, a bare word, a single-dash flag, and a lone-- followed by something that looks like an option:
args passed to SpringApplication.run: [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
CommandLineRunner String[] args = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
ApplicationRunner getSourceArgs() = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
ApplicationRunner getOptionNames() = [name, verbose]
ApplicationRunner getOptionValues(name) = [Ankur, Second]
ApplicationRunner getOptionValues(verbose) = []
ApplicationRunner getOptionValues(missing) = null
ApplicationRunner containsOption(verbose) = true
ApplicationRunner getNonOptionArgs() = [data.csv, -x, --after-the-dashes]
Environment property 'name' (both values, joined by a comma): Ankur,Second
| You want… | CommandLineRunner | ApplicationRunner |
|---|---|---|
| the raw strings, in order | String... args | args.getSourceArgs() |
the value of --name=Ankur | parse it yourself | args.getOptionValues("name") → a list |
to know --verbose was present | search the array | args.containsOption("verbose") |
the plain words (data.csv) | filter out anything starting with -- | args.getNonOptionArgs() |
[Ankur, Second]), and the Spring Environment sees the same property as the single string Ankur,Second. And everything after a lone -- is treated as non-option, but the -- itself is dropped from getNonOptionArgs() (it is still in the source args); the single-dash -x is not an option to Spring, only --name style is.
The practical advice is plain: if you only read a file name or a flag, CommandLineRunner is fine. As soon as you catch yourself writing args[0].startsWith("--"), switch to ApplicationRunner. Neither type has any other capability: same moment, same ordering rules, same failure behaviour (all shown next).
Going deeper:
- ArgsApp.java — the runners that print the table above
- Reference: accessing application arguments
Both kinds of runner are one list, sorted by @Order — and ties are not stable
A very common belief is that Boot calls allApplicationRunner beans and then all CommandLineRunner beans. It does not. Both interfaces extend a package-private marker interface, org.springframework.boot.Runner (read with javap in output/16-javap-callrunners.txt), and SpringApplication.callRunners collects every bean of that type into one collection and sorts it once. From output/15-callrunners-source.txt:
private void callRunners(ConfigurableApplicationContext context, ApplicationArguments args) {
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
String[] beanNames = beanFactory.getBeanNamesForType(Runner.class);
Map<Runner, String> instancesToBeanNames = new IdentityHashMap<>();
for (String beanName : beanNames) {
instancesToBeanNames.put(beanFactory.getBean(beanName, Runner.class), beanName);
}
Comparator<Object> comparator = getOrderComparator(beanFactory)
.withSourceProvider(new FactoryAwareOrderSourceProvider(beanFactory, instancesToBeanNames));
instancesToBeanNames.keySet().stream().sorted(comparator).forEach((runner) -> callRunner(runner, args));
}
So @Order(1) on an ApplicationRunner and @Order(2) on a CommandLineRunner run in exactly that order, and a lower number means earlier. The scenario in OrderingApp.java declares seven runners in a deliberately scrambled order. Using @Order on a class, on a @Bean method, and the Ordered interface all work, and the sort puts them here (output/02-order-annotation-and-ordered.txt):
(declaration order in OrderingApp.java is NOT the order below)
CommandLineRunner @Order(HIGHEST_PRECEDENCE)
ApplicationRunner @Order(1) on the class
CommandLineRunner @Order(2) on the class
ApplicationRunner implements Ordered, getOrder() = 3
Both @Order(4) -> ApplicationRunner.run
Both @Order(4) -> CommandLineRunner.run
CommandLineRunner @Order(5) on a @Bean method
ApplicationRunner no order at all (lowest precedence)
Two oddities are visible. A bean that implements both interfaces (Both) is called twice at the same position, ApplicationRunner.run first and then CommandLineRunner.run, because callRunner checks instanceof for each. And the runner with no order at all runs last, since an unordered bean is treated as lowest precedence.
What happens when two runners have the same order, or none? Nothing is promised, and the implementation makes it genuinely arbitrary: the collection is anIdentityHashMap, whose iteration order depends on identity hash codes, which differ from JVM to JVM. Four runners with no@Order, started in eight fresh JVMs (output/03-ties-have-no-promised-order.txt), produced four different sequences:JVM 1: arD arB cliC cliA JVM 2: cliC cliA arD arB JVM 3: cliA arB arD cliC JVM 4: arD arB cliC cliA JVM 5: arD arB cliC cliA JVM 6: cliC cliA arB arD JVM 7: cliC cliA arB arD JVM 8: arD arB cliC cliA distinct sequences seen: 4 arD arB cliC cliA x4 cliC cliA arD arB x1 cliA arB arD cliC x1 cliC cliA arB arD x2
Your own run will differ; that is the point. If runner B needs runner A to have finished, give them different@Ordervalues. Do not rely on “application runners first”.
Where did the old behaviour go? Source of Boot 3.0.13 versus 3.1.12 and later
I read
callRunners in the sources of several Boot lines. Boot 3.0.13 used context.getBeanProvider(Runner.class).orderedStream() (shown in the 3.0.13 section of output/15-callrunners-source.txt), where unordered beans come out in registration order. The IdentityHashMap version is already there in 3.1.12 and is the same in 3.2.12, 3.3.13, 3.5.16 and 4.1.2 (I read those sources; I only ran 4.1.2). The FactoryAwareOrderSourceProvider in the excerpt is what lets Boot see an @Order placed on the @Bean method that produced a runner (transcript 02 shows that working). That the ties lost their stable order is something I observed in the 4.1.2 runs; I did not find it documented. So a project that “worked” with unordered runners on an old 3.0 line and moved to 3.1 or later may have started to see a different sequence.
- @Order Javadoc (lower value = higher priority)
- output/16-javap-callrunners.txt —
new java/util/IdentityHashMapin the compiledcallRunners - TiesApp.java — the four unordered runners
If a runner throws, the application does not start
A runner is part of startup. When one throws,SpringApplication.run fails, the context is closed, and the JVM exits with a non-zero code. The scenario in FailureApp.java has three ordered runners where the second throws, plus a listener for each interesting event. It is started in a separate JVM so the exit code is real. From output/05-runner-failure.txt (the TRACE lines are the application’s own trace output; the ERROR line and exception lines come from the log):
--- three ordered runners, the second throws (non-web, in a separate JVM) ---
TRACE ApplicationStartedEvent
TRACE runner 1 ran
TRACE runner 2 is about to throw
TRACE ApplicationFailedEvent: java.lang.IllegalStateException: boom from runner 2
TRACE ContextClosedEvent (the context is shutting down)
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: boom from runner 2
process exit code: 1
Runner 1 ran, runner 2 threw, runner 3 never ran, ApplicationReadyEvent never fired, and the process ended with exit code 1. ApplicationFailedEvent was published, so a listener on it can log or alert. A runtime exception comes through unchanged; a checked exception (a runner’s run method is declared throws Exception) is wrapped, and an exception that implements ExitCodeGenerator chooses the exit code (same file, output/05-runner-failure.txt):
--- a runner exception that implements ExitCodeGenerator ---
ERROR o.s.b.SpringApplication : Application run failed
com.ankurm.runners.failure.FailureApp$ExitWith42Exception: the data file is missing
process exit code: 42
--- a runner that throws a checked exception (declared with throws Exception) ---
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: Failed to execute CommandLineRunner
Caused by: java.io.IOException: cannot read data.csv
process exit code: 1
The wrapping comes from the ThrowingConsumer in callRunner (the third excerpt in transcript 15 shows the "Failed to execute " message). On Kubernetes a container that exits at startup is typically restarted over and over (I did not run that part here); on a developer machine it is a stack trace and a prompt. Decide deliberately whether a given startup task is allowed to take the application down.
The catch-and-continue pattern. If the task is optional (a warm-up, a non-critical sync), catch the exception inside the runner, log it, and return. Then runners after it still run and the application still becomes ready. If it is required (the schema must exist), let it throw and let the process fail loudly. Both are legitimate; the bug is not choosing.Going deeper:
- Reference: application exit and ExitCodeGenerator
- output/05-runner-failure.txt — includes the exit-code-42 and checked-exception runs
If an ApplicationReadyEvent listener throws, the application dies too — after it said it was ready
It is tempting to think that a ready-event listener is a harmless side channel: the application is up, the listener is a bonus. It is not. The listener is called fromSpringApplication.run itself, and the last lines of run (the listeners.ready(...) block in the excerpt in transcript 15) wrap it in the same failure handling. I ran it both without and with a web server. For the web case the listener first calls its own /ping endpoint, to prove the server really was answering when it threw (output/06-ready-listener-failure.txt):
--- web application (Tomcat is up when the listener runs) ---
TRACE ApplicationStartedEvent
TRACE GET /ping from inside the listener -> 200 pong
TRACE ready listener 1 is about to throw (readiness: REFUSING_TRAFFIC)
TRACE ContextClosedEvent (the context is shutting down)
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: boom from the ready listener
process exit code: 1
does the port answer after the process ended? false
Look at what that shows. The web server was serving requests (200 pong), the listener threw, the context was closed, the process exited with code 1, and afterwards the port no longer answers. The readiness state seen from inside the listener was still REFUSING_TRAFFIC, so a probe never saw a “ready” application that then collapsed, but any request that arrived in that window was served and then the server went away. Also, a second ready listener ordered after the failing one (the scenario has one) never ran.
There is one more difference from the runner case, and it is easy to miss: in the runner transcript an ApplicationFailedEvent was received; here the observer bean that listens for it printed nothing. The final line of run passes null instead of the run listeners to handleRunFailure (the second-to-last excerpt line in transcript 15), and the call that publishes ApplicationFailedEvent is guarded by listeners != null (I read that in the 4.1.2 sources). So a failure in a ready listener is reported only as the logged error and the exit code, with no ApplicationFailedEvent for your monitoring to hook.
Rule of thumb. A listener on ApplicationReadyEvent is not “after startup, safe”. It fails the application exactly like a runner does. If you want best-effort behaviour, catch inside it.
Going deeper:
- output/06-ready-listener-failure.txt — the non-web run too
- Spring Application Events — why an exception in a synchronous listener reaches the publisher
A slow runner keeps readiness red — but the port is already open
Because the runners run before readiness flips, a runner that takes 40 seconds delays the application’s “ready” state by 40 seconds. In a web application there is a second subtlety: the web server started earlier, in step 2. So the application accepts connections while it reports itself as not ready. The test behind output/07-blocking-runner-readiness.txt starts a web application with actuator health probes enabled and a runner that waits on a latch (no sleeps), and queries it while the runner is blocked:--- while the runner is blocked ---
has SpringApplication.run() returned? false
GET /ping -> 200 pong
GET /actuator/health/liveness -> 200 {"status":"UP"}
GET /actuator/health/readiness -> 503 {"status":"OUT_OF_SERVICE"}
--- after the runner returned ---
has SpringApplication.run() returned? true
GET /actuator/health/readiness -> 200 {"status":"UP"}
While the runner blocks, SpringApplication.run() has not returned, /ping is answered with 200, the liveness probe is UP, and the readiness probe is 503 OUT_OF_SERVICE. Once the runner returns, readiness is UP. (In the trace the runner reports a thread called app-main because the test starts the application on its own thread; in a real application it is main.)
Do not rely on the red readiness probe to keep traffic out. Only components that consult the probe (Kubernetes, a load balancer) keep traffic away. Anything that calls the port directly gets served, asGoing deeper:200 pongshows. If a request must not run before the runner finishes, the code that serves it has to check, for example viaApplicationAvailability.
- Spring Boot 4 on Kubernetes: probes, graceful shutdown, CPU limits and HPA — what the probes do with that 503
- Why your Spring Boot app takes 8 seconds to start — measure what startup is spending its time on before moving work around
- BlockingApp.java and Gate.java
If the work can wait, hand it to another thread — and own its failures
When the work does not have to finish before traffic arrives, return from the runner at once and do the work elsewhere. The same application with its alternative runner starts the work on a virtual thread and returns. The work then fails after readiness is up (output/08-async-runner.txt):
--- the background work is still waiting ---
has SpringApplication.run() returned? true
GET /actuator/health/readiness -> 200 {"status":"UP"}
--- after the background work threw ---
GET /actuator/health/readiness -> 200 {"status":"UP"}
GET /ping -> 200 pong
--- trace ---
runner: started the work on a virtual thread and returned
background work on a virtual thread failed: the warm-up blew up
The runner reported success immediately, readiness was UP while the background task was still waiting, and when the task threw, the application did not notice: readiness stayed UP and /ping kept working. That is the trade. You gain startup speed and you lose Spring’s failure handling, so the thread has to deal with its own errors (log them, retry, flip a flag, publish a health indicator). In this demo the failure was only visible because the thread had an uncaught-exception handler that wrote to the trace.
Going deeper:
- Spring Boot 4: async executors and virtual threads — use a managed executor rather than a raw thread in real code
- output/08-async-runner.txt
When the thing you start must also be stopped, use SmartLifecycle
Runners and ready listeners are for one-off startup work. Their API has a start and no stop. If what you launch is long-lived — a queue consumer, a scheduler, a socket, a poller — you also need to stop it cleanly and in a sensible order when the application shuts down. That is the job ofSmartLifecycle: Spring calls start() during startup and stop() during shutdown, ordered by a phase number.
The scenario in LifecycleApp.java registers five lifecycle beans (low -100, mid 0, manual 50 with isAutoStartup() false, high 100, slow 200 whose stop takes 300 ms), a runner and event listeners. The class that implements the interface is Svc.java. Here is the whole run, from output/09-smartlifecycle-phases.txt:
--- startup ---
start low (phase -100) on main
start mid (phase 0) on main
start high (phase 100) on main
start slow (phase 200) on main
ApplicationStartedEvent
CommandLineRunner runs
ApplicationReadyEvent
manual.isAutoStartup()=false isRunning()=false (never started by the context)
--- context.close() ---
start manual (phase 50) on main
ContextClosedEvent (stop phase begins)
stop slow (phase 200) begins, will finish in 300 ms
stop slow finished, calling the callback
stop high (phase 100) on main
stop manual (phase 50) on main
stop mid (phase 0) on main
stop low (phase -100) on main
ApplicationStartedEvent and the runner, so a runner can rely on every auto-started lifecycle bean being up. Shutdown is the exact mirror: ContextClosedEvent, then descending phases. A bean with isAutoStartup() false (manual) is not started by the context at all; in the run the test starts it by hand, and it is then stopped with the rest. And the stop(Runnable callback) variant lets a bean take its time: slow announced its stop, finished 300 ms later on another thread, called the callback, and only then did the next phase begin. That wait is bounded by spring.lifecycle.timeout-per-shutdown-phase.
Two ways a SmartLifecycle goes wrong (both run)
start() throws. The failure happens during the context refresh, before
ApplicationStartedEvent and before any runner, and the beans that did start are stopped on the way out. From output/11-smartlifecycle-start-fails.txt:
start healthy (phase 0)
start broken (phase 10)
stop healthy
application started? false
org.springframework.context.ApplicationContextException: Failed to start bean 'broken'
java.lang.IllegalStateException: could not connect to the broker
isRunning() never returns true. Spring calls start() only when isRunning() is false, and calls stop() only when it is true (doStart and doStop in output/17-lifecycleprocessor-source.txt). Forget to record the state and your stop() is silently never called (output/12-smartlifecycle-forgot-isrunning.txt):
--- startup ---
start() was called
--- after context.close() ---
(nothing)
SmartLifecycle is not a fancier runner. A runner is the right tool for a task that finishes. Do not put a never-returning loop in a runner (it blocks readiness forever, as the blocking section showed) and do not useGoing deeper:SmartLifecycle.start()for a long task either —start()is also called on the main startup thread and must return quickly; spawn the worker thread from it and stop that thread instop().
- SmartLifecycle Javadoc
- Spring bean lifecycle: @PostConstruct, SmartLifecycle and shutdown order
- output/17-lifecycleprocessor-source.txt —
DefaultLifecycleProcessorstart and stop conditions
In tests, @SpringBootTest runs your startup hooks; a web slice runs only some of them
A startup hook that does real work (it calls a service, writes a file) also runs when you test, which is a frequent surprise. The scenario in SlicesApp.java declares four hooks four ways: a top-level@Component runner (ComponentRunner.java), a top-level @Component ready listener (ReadyListener.java), a runner declared as a @Bean method, and a runner as a @Component class nested inside SlicesApp. With @SpringBootTest(args = "--from-the-test=1") everything runs before the first test method, and the runners receive the test’s arguments (output/13-spring-boot-test-runs-the-hooks.txt):
CommandLineRunner from a top-level @Component ran, args = [--from-the-test=1]
CommandLineRunner nested in SlicesApp ran, args = [--from-the-test=1]
ApplicationRunner from a @Bean method ran, source args = [--from-the-test=1]
ApplicationReadyEvent listener (a top-level @Component) ran
With @WebMvcTest(controllers = Hello.class) only two of the four ran (output/14-webmvctest-slice-hooks.txt):
CommandLineRunner nested in SlicesApp ran, args = []
ApplicationRunner from a @Bean method ran, source args = []
The slice filters out ordinary scanned @Component classes, so the scanned runner and the ready listener are gone. The @Bean-method runner survives because it lives on the application class that every slice loads. The nested class survived too, and that one surprised me while writing the scenario: classes nested inside a configuration class are registered as part of that class, so no scan filter removes them. (My first version nested everything, and the slice ran all four hooks until I moved them out; the probe that showed it was not kept.) Practical consequence: if a runner must not run in slices, make it a top-level @Component, or guard it with @Profile or a property.
Going deeper:
- WebMvcSliceTests.java and SpringBootTestHooksTests.java
- Awaitility: testing async Spring Boot code — if a hook starts background work, wait for it properly in the test
Which one should I use?
| You want to… | Use | Because |
|---|---|---|
| run a short task once, with the raw arguments | CommandLineRunner | runs before readiness, ordered with @Order, fails the start if it throws |
run a short task once and read --options | ApplicationRunner | the same, with parsed arguments |
| react to “the application has started”, after every runner | @EventListener(ApplicationReadyEvent.class) | runs after all runners; still fails the start if it throws; readiness is not yet UP |
| start something long-lived that must be stopped in order | SmartLifecycle | start and stop, phases, async stop; runs before the runners |
| prepare a single bean’s own state | @PostConstruct | per bean, early; do not depend on other beans being started |
| do slow work that may finish after startup | a runner that hands off to an executor | readiness is not delayed; you own the failures |
Should you put this much in startup at all? Probably less than you think. Every runner is code that can crash your deployment, delays readiness on every restart, and runs again in every test that loads the context. Before adding one, ask whether the task belongs somewhere else: a schema change belongs in a migration tool, a periodic job belongs in a scheduler, and a lookup table that is only needed on first use can be loaded lazily. A runner is the right home for a small, fast, required check (“does this directory exist”) or a deliberate warm-up. That last paragraph is my opinion, not something the transcripts prove; what they prove is the cost side: failure kills the start, and slowness holds readiness down.
What I had to correct or check along the way
Things that were not what I expected.
1. Boot does not runApplicationRunners beforeCommandLineRunners: one list, one sort, and ties come out in an arbitrary order (four sequences in eight JVMs).
2. A runtime exception from a runner is not wrapped; only a checked one becomesIllegalStateException: Failed to execute CommandLineRunner.
3. A throwingApplicationReadyEventlistener ends the application and publishes noApplicationFailedEvent.
4.ApplicationReadyEventarrives while readiness is stillREFUSING_TRAFFIC; the flip is the next statement.
5. The web server starts before default-phaseSmartLifecyclebeans, so such a bean does not delay the port opening.
6. Classes nested in a configuration class are not removed by a test slice’s component filter.
7. Maven Central’s<release>element forspring-boot-starter-parentpoints at4.2.0-RC2; the newest GA is4.1.2.
Further reading
- Companion project: asmhatre/spring-boot-demo/startup-runners, with all 18 transcripts
- Spring bean lifecycle: @PostConstruct, SmartLifecycle and shutdown order
- Spring Application Events
- Spring Boot 4 on Kubernetes: probes and graceful shutdown
- Why your Spring Boot app takes 8 seconds to start
- Spring Boot JDK AOT cache (JEP 514/515) startup benchmarks — if the real problem is slow startup, not what to run at startup
- Spring Boot reference: SpringApplication
- SmartLifecycle Javadoc
No Comments yet!