Spring Boot Startup Optimization: From 6.2s to 1.8s via Profiling, Lazy Loading & Event-Driven Init

A real-world case study shows how systematic profiling with Spring Boot Actuator and JFR, combined with scan-scope reduction, selective @Lazy on external clients, HikariCP deferred connection, and ApplicationReadyEvent-driven async initialization cut a Spring Boot 3.1.5 order-service startup from 6.2 seconds to 1.8 seconds on Java 17.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Spring Boot Startup Optimization: From 6.2s to 1.8s via Profiling, Lazy Loading & Event-Driven Init

Why Startup Time Matters in Cloud-Native

In containerized environments, startup latency directly impacts horizontal pod autoscaler (HPA) responsiveness, rolling-deployment windows, and developer inner-loop productivity. The author identifies three pain points: HPA decision cycles measured in minutes while pods take 30+ seconds to become ready; extended release windows due to slow health checks; and local developer context-switching during 10-20 second restarts.

Spring Boot Startup Phase Breakdown

Calling SpringApplication.run() triggers nine major phases:

SpringApplication initialization — reads spring.factories and AutoConfiguration.imports SPI files; accumulates to hundreds of milliseconds with many dependencies.

Environment assembly — creates ConfigurableEnvironment, loads system properties, env vars, application.yml; adds network overhead if using Nacos/Apollo.

BeanFactory creation — instantiates DefaultListableBeanFactory and registers standard post-processors.

Configuration-class parsing — processes @ComponentScan, @Import, @Bean via heavy reflection and metadata operations; a primary time consumer.

Component scanning — walks classpath for @Component, @Service, @Repository, @Controller; cost grows with scan breadth.

Auto-configuration — evaluates @ConditionalOnXxx conditions and instantiates matching configuration classes.

Bean instantiation & initialization — constructor calls, @PostConstruct, InitializingBean, AOP proxying; second-largest cost when bean count and dependency chains are large.

External resource connections — database pools, Redis, RPC (Dubbo/Feign) clients; network handshake time counted in startup.

ApplicationContext finalization — fires ContextRefreshedEvent, ApplicationStartedEvent, ApplicationReadyEvent listeners for cache warm-up, scheduled tasks, etc.

Profiling Tools

Startup Actuator (Spring Boot 2.4+)

Add spring-boot-starter-actuator and expose the startup endpoint:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: startup

Request /actuator/startup for JSON timeline; focus on high-share nodes like spring.beans.instantiate.

JDK Flight Recorder (JFR)

Built-in, no extra deps. Record with:

java -XX:StartFlightRecording=filename=startup.jfr,duration=60s,settings=profile -jar your-app.jar

Analyze via jfr print to see thread blocking, socket latency, class-loading patterns.

Custom Listener

Lightweight timestamp capture for key events:

public class StartTimingListener implements ApplicationListener<ApplicationContextInitializedEvent> {
  private final long start = System.nanoTime();
  private final Map<Class<? extends ApplicationEvent>, Long> timeRecording = new HashMap<>();

  @Override
  public void onApplicationEvent(ApplicationContextInitializedEvent event) {
    record(event);
  }

  private <E extends ApplicationEvent> void record(E event) {
    long now = System.nanoTime();
    timeRecording.put(event.getClass(), now - start);
  }
}

Track intervals between ApplicationContextInitializedEvent, ApplicationEnvironmentPreparedEvent, ApplicationPreparedEvent, ContextRefreshedEvent, ApplicationReadyEvent.

Optimization 1: Shrink Scan Scope

Avoid Default Global Scan

@SpringBootApplication

defaults to current package + sub-packages. For a com.company prefix with 10k+ classes, replace with explicit annotations:

@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(basePackages = "com.example.module.user")
public class UserServiceApplication {}

Use @Import for Known Components

Manually import framework wrappers to skip classpath traversal:

@Configuration
@Import({MyCacheClient.class, MyRpcProxy.class, MyInfraHandler.class})
public class CoreInfrastructureConfig {}

Disable Unused Auto-Configurations

Exclude irrelevant starters (e.g., Mongo, DataSource for an API gateway):

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration
      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration

Removes both bean creation and conditional-evaluation overhead.

Make "Eager" Beans Conditional

Gate heavy pre-load logic behind a property:

@Configuration
@ConditionalOnProperty(name = "order.preload-strategy", havingValue = "full", matchIfMissing = false)
public class OrderPreloadConfig {}

Optimization 2: Targeted Lazy Loading

Global Lazy Initialization Risks

spring.main.lazy-initialization=true

defers all non-essential beans but introduces:

Configuration errors surface only on first request ("time bomb").

First request latency spikes due to cascading bean creation.

Pre-warm logic (data loading, connection setup) never runs.

Recommended only for demo / function-as-a-service workloads.

Selective @Lazy on High-Cost Resources

Annotate specific beans: DB connections, Redis, RPC clients, MQ producers, search clients.

@Configuration
public class ExternalClientConfig {

  @Bean
  @Lazy
  public RedisConnectionFactory redisConnectionFactory() {
    return new LettuceConnectionFactory(redisHost, redisPort);
  }

  @Bean
  @Lazy
  public MyRpcClient myRpcClient() {
    return new MyRpcClient(rpcEndpoint, maxConnection);
  }
}

Also place @Lazy on injection points to prevent eager pull-through.

LazyInitializationExcludeFilter for Global + Exceptions

If global lazy is required, protect core packages:

@Bean
public LazyInitializationExcludeFilter lazyExcludeFilter() {
  return candidate -> candidate.getBeanClassName() != null
      && (candidate.getBeanClassName().startsWith("com.example.core")
          || candidate.getBeanClassName().equals("org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"));
}

Optimization 3: Parallel & Async Initialization

Virtual Thread Executor (Spring Boot 3.2+, JDK 21)

Offload I/O-bound post-startup tasks (cache warm-up, index loading, DNS resolution) to virtual threads:

@Bean(name = "startupInitExecutor")
public Executor startupInitExecutor() {
  return Executors.newVirtualThreadPerTaskExecutor();
}

Defer External Connections

HikariCP config to avoid startup-time TCP handshake:

spring:
  datasource:
    hikari:
      minimum-idle: 0
      initialization-fail-timeout: -1
DataSource

bean becomes a lightweight shell; real connection occurs on first getConnection(). Lettuce Redis client supports similar lazy connect.

Event-Driven Post-Initialization

Move heavy @PostConstruct work to ApplicationReadyEvent with async executor:

@Component
public class CacheWarmer implements ApplicationListener<ApplicationReadyEvent> {
  private final Executor executor = Executors.newVirtualThreadPerTaskExecutor();
  private final UserCache userCache;

  @Override
  public void onApplicationEvent(ApplicationReadyEvent event) {
    executor.execute(() -> userCache.prewarmAll());
  }
}

Multiple tasks orchestrated via ApplicationRunner + CompletableFuture:

@Component
public class StartupTaskRunner implements ApplicationRunner {
  private final List<StartupTask> startupTasks;
  private final Executor executor;

  @Override
  public void run(ApplicationArguments args) {
    List<CompletableFuture<Void>> futures = startupTasks.stream()
        .map(task -> CompletableFuture.runAsync(task::run, executor))
        .toList();
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
        .whenComplete((v, ex) -> handleFailure(...));
  }
}

Key insight: this shifts work after readiness, trading "faster to serve" for "no work eliminated".

Case Study: Order Query Service (Spring Boot 3.1.5, Java 17)

Baseline Breakdown (6.2 s total)

Environment (config + Nacos): 0.4 s

BeanDefinition scan ( com.example.*, ~30k classes): 1.2 s

Auto-config condition evaluation: 0.8 s

DataSource (HikariCP connect + validate): 0.9 s

Redis init: 0.5 s

Kafka Producer init: 0.6 s

Business bean init: 1.3 s

Context refresh & listeners: 0.5 s

Applied Optimizations & Gains

Narrow scan to com.example.order + move shared libs to starter + @Import: −0.8 s

Exclude unused auto-configs (only Web MVC, JDBC, Kafka, Redis, Nacos kept): −0.5 s

Defer slow resources : HikariCP initialization-fail-timeout=-1; Kafka Producer @Lazy. Custom health indicator returns OUT_OF_SERVICE until async warm-up completes, preventing readiness-probe / lazy-init deadlock: −~2 s combined

Local @Lazy on non-core beans (schedulers, listeners, indexers); convert @PostConstruct pre-loads to async event-driven: −0.4 s

Parallelize event listeners on virtual threads: −0.2 s

Resulting Profile (~1.8 s total)

Scan + auto-config: 0.7 s

DataSource: 0 s at startup (first use later)

Redis/Kafka async warm-up: 0.2 s (lightweight objects only)

Business bean init: 0.9 s

Context refresh: 0.3 s

≈70 % faster; biggest wins from scan reduction (~2 s) and connection deferral (~2 s).

Long-Term Guardrails

CI Startup Regression Test

@Test
void checkStartupTime() {
  long start = System.currentTimeMillis();
  SpringApplication app = new SpringApplication(OrderQueryApplication.class);
  app.setApplicationStartup(new BufferingApplicationStartup(2048));
  try (ConfigurableApplicationContext ctx = app.run("--spring.profiles.active=test")) {
    long elapsed = System.currentTimeMillis() - start;
    assertTrue(elapsed < 2000, "启动耗时超出预期: " + elapsed);
  }
}

Run in Maven/Gradle verify phase; add threshold margin for CI noise.

Persist Per-Phase Metrics

Push /actuator/startup JSON to a time-series DB each release; Grafana dashboard reveals creeping regressions early.

Code-Review Red Lines

No new auto-configuration without usage audit.

No widening @ComponentScan scope.

No blocking calls in @PostConstruct or ApplicationRunner.

Every new external-connection bean must justify eager vs. ApplicationReadyEvent deferred init.

Closing Perspective

The optimization's real value is forcing the team to understand Spring Boot's startup mechanics. Default conventions yield predictable but slow starts; explicit component management, conditional assembly, and re-ordered initialization sequences unlock far more headroom than most expect — no magic, just layer-by-layer demystification of the framework's internals.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

microservicesSpring Bootvirtual threadsHikariCPProfilingLazy LoadingStartup PerformanceEvent-Driven Initialization
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.