What Actually Happens When Spring Boot Starts Up

by Eric Hanson, Backend Developer at Clean Systems Consulting

The startup sequence

Spring Boot startup is not a single operation. It's a sequence of phases, each with specific responsibilities:

  1. SpringApplication initialization — determines the application type (servlet, reactive, or none), loads ApplicationContextInitializer and ApplicationListener implementations from spring.factories/AutoConfiguration.imports
  2. Environment preparation — loads application.properties/application.yml, resolves profiles, applies property sources in precedence order
  3. ApplicationContext creation — creates the AnnotationConfigServletWebServerApplicationContext (for web applications) or appropriate subtype
  4. Bean definition loading — component scanning, @Configuration class processing, @Bean method registration
  5. Auto-configuration — Spring Boot's auto-configuration classes are evaluated and conditionally applied
  6. Context refresh — all bean definitions are instantiated, dependencies injected, lifecycle callbacks invoked
  7. Web server start — embedded Tomcat/Jetty/Undertow starts and begins accepting connections
  8. ApplicationReadyEvent — the application is ready to serve requests

Understanding which phase each piece of code runs in explains startup ordering bugs and why some configurations must appear before others.

Phase 2: Property loading order matters

Spring Boot loads properties from multiple sources in a specific precedence order (highest to lowest):

  1. Command-line arguments (--server.port=9090)
  2. SPRING_APPLICATION_JSON environment variable
  3. OS environment variables
  4. application-{profile}.properties (profile-specific, outside jar)
  5. application.properties (outside jar)
  6. application-{profile}.properties (inside jar)
  7. application.properties (inside jar)
  8. @PropertySource annotations on @Configuration classes
  9. Default properties

The practical implication: environment variables override properties files, which override @PropertySource. Container orchestration that sets environment variables will always win over bundled configuration — intentionally. @PropertySource annotations are loaded late (phase 4, during @Configuration processing) — they cannot override application.properties entries.

Profile activation order. Multiple profiles are processed in reverse order — the last profile listed wins for overlapping properties:

# application.yml
spring:
  profiles:
    active: db-prod, logging-verbose
# logging-verbose's properties override db-prod's for any overlap

Phase 4-5: Auto-configuration and @Conditional

Auto-configuration is Spring Boot's mechanism for applying sensible defaults based on what's on the classpath and in the environment. Each auto-configuration class is annotated with @Conditional annotations that determine whether it applies:

@AutoConfiguration
@ConditionalOnClass(DataSource.class)         // applies only if DataSource is on classpath
@ConditionalOnMissingBean(DataSource.class)   // applies only if no DataSource bean exists
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public DataSource dataSource(DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().build();
    }
}

@ConditionalOnMissingBean is the key pattern: auto-configuration applies only if the application hasn't already defined the bean. This is how you override auto-configured beans — define your own @Bean of the same type, and the auto-configuration backs off.

The auto-configuration classes to apply are listed in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (Spring Boot 2.7+, replacing spring.factories). Spring Boot reads this file from every JAR on the classpath during startup. Libraries that provide auto-configuration ship this file — this is how Spring Data JPA, Spring Security, and Actuator wire themselves automatically.

Debugging auto-configuration. When a bean isn't being created as expected, the auto-configuration report shows which configurations applied and which didn't — and why:

--debug flag or logging.level.org.springframework.boot.autoconfigure=DEBUG

This produces a "CONDITIONS EVALUATION REPORT" in the startup log showing each auto-configuration class, whether it matched, and the condition that failed if not.

Phase 6: Context refresh and bean instantiation order

During context refresh, Spring instantiates all singleton beans eagerly by default. The instantiation order follows the dependency graph — a bean is not instantiated until all its dependencies are ready.

@DependsOn for non-obvious dependencies. When bean A must be initialized before bean B, but A is not a constructor dependency of B — a common case with database migration tools like Flyway or Liquibase:

@Bean
@DependsOn("flywayInitializer")  // ensure Flyway runs before JPA initializes schema
public LocalContainerEntityManagerFactoryBean entityManagerFactory(...) {
    ...
}

Spring Boot handles the Flyway/JPA ordering automatically via FlywayAutoConfiguration. For custom tools or uncommon ordering requirements, @DependsOn makes the dependency explicit.

@Lazy for deferred initialization. By default, singleton beans are instantiated at startup. @Lazy defers instantiation to first use:

@Service
@Lazy
public class ExpensiveInitializationService {
    public ExpensiveInitializationService() {
        // called only when first injected, not at startup
    }
}

spring.main.lazy-initialization=true makes all beans lazy globally — faster startup, but startup-time errors are deferred to first use. Useful for development; evaluate carefully for production where fast-fail-at-startup is a feature.

Circular dependencies. A circular dependency — bean A depends on bean B, bean B depends on bean A — causes a BeanCurrentlyInCreationException with constructor injection (the default and preferred form). Spring can resolve circular dependencies with field or setter injection by using a partially constructed proxy, but this is a design smell. The correct fix is to break the cycle by extracting a third class that both depend on, or by restructuring the dependency direction.

Lifecycle callbacks — the order that matters

After a bean is instantiated and its dependencies injected, lifecycle callbacks fire in this order:

  1. BeanPostProcessor.postProcessBeforeInitialization() — framework hooks, runs before user code
  2. @PostConstruct method — user initialization code
  3. InitializingBean.afterPropertiesSet() — older interface-based initialization
  4. @Bean(initMethod = "init") — XML-style initialization method
  5. BeanPostProcessor.postProcessAfterInitialization() — framework hooks, AOP proxies created here
@Service
public class CacheWarmingService {

    @PostConstruct
    public void warmCache() {
        // Runs after all dependencies are injected, before the application is ready
        // Safe to call other beans here — they're all initialized
        loadFrequentlyAccessedData();
    }
}

@PostConstruct is the correct place for initialization that requires injected dependencies. The constructor is too early — dependencies may not be injected via field injection. ApplicationReadyEvent (phase 8) is for initialization that should run after the web server starts.

@PreDestroy is the inverse — called during graceful shutdown before the bean is destroyed. Use it to release resources, close connections, flush queues:

@PreDestroy
public void cleanup() {
    scheduler.shutdown();
    connectionPool.close();
}

Phase 7-8: Web server startup and readiness

The embedded web server starts after all beans are initialized. At this point, the application can accept HTTP requests. Spring Boot fires events at each transition:

@Component
public class StartupListener {

    @EventListener(ApplicationReadyEvent.class)
    public void onReady(ApplicationReadyEvent event) {
        // Runs after web server is started and application is ready for requests
        // Use for: registering with service discovery, warming HTTP caches,
        // starting background tasks that need the full application context
        log.info("Application started in {}ms",
            event.getTimeTaken().toMillis());
    }

    @EventListener(ApplicationStartedEvent.class)
    public void onStarted() {
        // Runs after context refresh but before web server starts
        // Use for: pre-start validation that should block startup on failure
    }
}

ApplicationReadyEvent vs ApplicationStartedEvent: ApplicationStartedEvent fires after the context refresh but before the web server accepts connections. ApplicationReadyEvent fires after the web server is started and the application is accepting requests. For tasks that should run before serving traffic, ApplicationStartedEvent. For tasks that only make sense once the application is live, ApplicationReadyEvent.

Startup time profiling

Spring Boot 2.5+ includes startup actuator data:

management.endpoints.web.exposure.include=startup

GET /actuator/startup returns a timeline of startup events with durations. Identify the slow phases — long bean initialization, slow auto-configuration, expensive @PostConstruct — and optimize them.

The --debug flag produces the full conditions evaluation report and bean definition loading log. For large applications where startup time matters, spring-context-indexer pre-computes the component scan index at build time, eliminating classpath scanning at startup:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context-indexer</artifactId>
    <optional>true</optional>
</dependency>

This generates a META-INF/spring.components file at build time. At runtime, Spring reads the index instead of scanning the classpath. For large applications, this can cut startup time by 30–50%.

The startup bug categories that the sequence explains

Bean not found despite being annotated. Component scanning didn't reach the package. Either the @SpringBootApplication base package doesn't cover the bean's package, or the bean's package is missing from @ComponentScan. Check the component scan coverage.

Auto-configuration not backing off. A custom @Bean didn't suppress auto-configuration. The auto-configuration's @ConditionalOnMissingBean looks for an exact type match — if your bean is a subtype or implements the interface but isn't the exact type, the condition doesn't fire. Check the conditions evaluation report.

Property not resolved. A property reference like ${my.prop} results in a PlaceholderResolutionException. Either the property isn't in any loaded source, or the source is loaded at the wrong phase (a @PropertySource loaded in phase 4 can't override environment variables loaded in phase 2).

@PostConstruct fails. A dependency isn't ready. If the @PostConstruct method calls an external service — database, HTTP endpoint — and that service isn't available at startup, the failure prevents the bean from initializing and cascades through the bean graph. Move external service calls to ApplicationReadyEvent if startup should not be blocked by external unavailability.

Each of these bugs has a specific cause rooted in startup phase ordering. Knowing the sequence makes the diagnosis systematic rather than guesswork.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How Async Communication Improves Developer Productivity

Interruptions are productivity killers. Async communication lets developers focus without constant context switching.

Read more

Why Auckland Startups Have an Unfair Advantage When They Hire Async — and Most Don't Know It Yet

Auckland sits in one of the earliest timezones on the planet. Most founders see that as isolation. It's actually a scheduling superpower.

Read more

Dublin's Best Backend Developers Work for Google and Meta — What the Rest of Us Do

You posted a backend role three weeks ago. The only applicants who fit are already at a FAANG company and just "seeing what's out there." They're not leaving.

Read more

Ruby Idioms That Replace Five Lines With One — And When Not To

Ruby has a deep bench of one-liner idioms that compress common patterns into expressive single expressions. Most are worth knowing. Several are worth avoiding. Here is an honest breakdown of both.

Read more