What Actually Happens During Spring Boot Startup

Every Spring Boot application starts with the same line:
SpringApplication.run(Application.class, args);
Simple. But behind that single method call, Spring Boot performs a surprisingly complex sequence of operations.
It creates the ApplicationContext, instantiates beans, injects dependencies, initializes the embedded web server, publishes lifecycle events, executes startup runners and finally declares the application ready to accept traffic.
Most developers know one or two startup callbacks — usually @PostConstruct or ApplicationRunner—and use them whenever startup logic is needed.
Eventually, questions appear.
- When are all beans available?
- Is Tomcat already listening?
- When should I warm up caches?
- What’s the difference between ApplicationStartedEvent and ApplicationReadyEvent?
- When is it actually safe to interact with other beans?
To answer those questions, let’s observe the entire startup lifecycle in one small application.
⚡ TL;DR (Quick Recap)
- Spring Boot startup consists of several distinct lifecycle phases, each exposing different extension points.
- Bean callbacks (@PostConstruct, InitializingBean, initMethod) initialize individual beans, while SmartInitializingSingleton executes once after all eager singleton beans exist.
- ApplicationRunner, CommandLineRunner and ApplicationReadyEvent each represent a different stage of application startup.
A single application demonstrating every startup hook
Rather than introducing callbacks one by one, the following example implements nearly every commonly used startup extension point.
Each callback simply logs when it executes.
class LifecycleBean implements InitializingBean, SmartInitializingSingleton, ApplicationRunner, CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(LifecycleBean.class);
public LifecycleBean() { log.info("03 Constructor"); }
@PostConstruct
void postConstruct() { log.info("04 @PostConstruct"); }
@Override
public void afterPropertiesSet() { log.info("05 InitializingBean.afterPropertiesSet()"); }
public void start() { log.info("06 @Bean(initMethod)"); }
@Override
public void afterSingletonsInstantiated() { log.info("6.5 SmartInitializingSingleton"); }
@EventListener(ContextRefreshedEvent.class)
void onContextRefreshed() { log.info("07 ContextRefreshedEvent"); }
@EventListener(ApplicationStartedEvent.class)
void onStarted() { log.info("07b ApplicationStartedEvent"); }
@Override
public void run(ApplicationArguments args) { log.info("08 ApplicationRunner"); }
@Override
public void run(String... args) { log.info("09 CommandLineRunner"); }
@EventListener(ApplicationReadyEvent.class)
void onReady() { log.info("10 ApplicationReadyEvent"); }
}
@SpringBootApplication
public class StartupApplication {
private static final Logger log = LoggerFactory.getLogger(StartupApplication.class);
public static void main(String[] args) {
var app = new SpringApplication(StartupApplication.class);
app.addInitializers(ctx -> log.info("01 ApplicationContextInitializer"));
app.run(args);
}
@Bean(initMethod = "start")
LifecycleBean lifecycleBean() {
log.info("02 @Bean factory method");
return new LifecycleBean();
}
}This example intentionally places every lifecycle callback into a single bean. In a real application, these responsibilities are usually spread across multiple components.
Spring Boot startup timeline
SpringApplication.run()
│
▼
ApplicationContextInitializer
│
▼
Bean creation
├─ Constructor
├─ @PostConstruct
├─ InitializingBean
└─ @Bean(initMethod)
│
▼
SmartInitializingSingleton
│
▼
ContextRefreshedEvent
│
▼
ApplicationStartedEvent
│
▼
ApplicationRunner
│
▼
CommandLineRunner
│
▼
ApplicationReadyEvent
│
▼
Application ready
Once grouped this way, the lifecycle becomes much easier to understand.
Phase 1 — Bean initialization
Everything begins with individual bean creation.
@Bean factory method
│
▼
Constructor
│
▼
@PostConstruct
│
▼
InitializingBean
│
▼
@Bean(initMethod)
These callbacks execute once for every bean Spring creates.
Constructor
Spring instantiates the object. Field- and setter-injected dependencies aren’t available yet — avoid using them here. Constructor-injected dependencies are safe to use immediately, since Spring passes them in as constructor arguments before the object exists.
@PostConstruct
Dependencies have been injected for this bean. Typical responsibilities include:
- validating configuration
- initializing internal state
- opening local resources
The callback only guarantees that the current bean is ready — not that every other bean already exists.
InitializingBean.afterPropertiesSet()
This callback follows immediately after @PostConstruct. It provides the same capability through a Spring interface instead of a Jakarta annotation and is commonly used by Spring infrastructure itself.
Bean(initMethod)
Sometimes the class you’re creating belongs to a third-party library. Instead of modifying the class, Spring can invoke a configured initialization method automatically.
Phase 2 — Every singleton bean exists
Once Spring finishes creating every eager singleton, it executes one callback that many developers never encounter.
SmartInitializingSingleton
Unlike the previous callbacks, this executes only once. This is the first moment where every eagerly initialized singleton bean exists. Lazy beans and prototype beans may still be created later.
That makes it an excellent place for:
- discovering other beans
- building registries
- initializing plugin systems
- performing cross-bean validation
- connecting multiple components together
If your startup logic depends on several beans existing simultaneously, this is often the correct lifecycle hook.
Phase 3 — The application context starts
Once bean initialization finishes, Spring refreshes the application context. Two important events follow.
ContextRefreshedEvent
│
▼
ApplicationStartedEvent
Although they’re close together, they represent different milestones.
ContextRefreshedEvent
The ApplicationContext has completed its refresh cycle. Every singleton bean has been initialized. The embedded web server has also been created.
ApplicationStartedEvent
Spring Boot has completed framework startup. However, application startup runners have not executed yet. Think of this as the hand-off from framework initialization to application initialization.
Phase 4 — Application startup
Now Spring invokes your startup code.
ApplicationRunner
│
▼
CommandLineRunner
│
▼
ApplicationReadyEvent
ApplicationRunner
Receives parsed command-line arguments through the ApplicationArguments API. A common place for:
- loading reference data
- validating startup configuration
- preparing caches
- registering services
CommandLineRunner
Receives the raw String[] arguments instead. Both interfaces are designed for application startup logic. One interesting observation from the demo is that ApplicationRunner executes before CommandLineRunner.
Although this is commonly observed, don’t rely on this ordering unless you explicitly define @Order. If execution order matters, make it explicit.
ApplicationReadyEvent
This is the final lifecycle event. When it fires:
- every singleton bean exists
- the application context has been refreshed
- the embedded server is running
- startup runners have completed
- Spring considers the application ready to serve requests
If external systems should interact with your application only after startup has fully completed, this is the event to use.
If any bean callback or runner throws, ApplicationReadyEvent never fires — Spring instead publishes ApplicationFailedEvent and startup aborts.
Which lifecycle hook should you choose?
Rather than memorizing APIs, ask one simple question:
What must already exist before my code runs?
Use:
- Constructor — object creation only.
- @PostConstruct — initialize one bean after dependency injection.
- InitializingBean — framework-oriented bean initialization.
- @Bean(initMethod) — initialize third-party classes.
- SmartInitializingSingleton — initialize once after every eager singleton exists.
- ContextRefreshedEvent — react to a fully refreshed application context.
- ApplicationStartedEvent — framework startup completed, runners haven't executed yet.
- ApplicationRunner — application startup logic with parsed arguments.
- CommandLineRunner — application startup logic with raw arguments.
- ApplicationReadyEvent — the application is fully operational.
Thinking about prerequisites instead of annotations makes selecting the correct hook surprisingly straightforward.
Final Takeaways
SpringApplication.run() may look like a single operation, but it orchestrates a carefully ordered startup lifecycle. Every callback exists because the application reaches a different level of readiness—from constructing an individual bean to accepting production traffic.
Understanding these phases helps you place initialization logic where it belongs. Bean-specific work stays with bean lifecycle callbacks, application-wide coordination belongs after all singletons exist, and interactions with external systems are safest once the application is fully ready.
Once you see startup as four logical phases instead of a collection of unrelated annotations and interfaces, Spring Boot’s lifecycle becomes much easier to reason about — and much easier to use correctly.
You can find an example on GitHub.
Originally posted on marconak-matej.medium.com.