
“Spring Boot is slow to start” is a claim about a measurement nobody took. The startup of a typical service is three distinct costs in a proportion that varies wildly between applications, and the fix for each is different. Determining the split takes about ten minutes.
Count the classes first
Before instrumenting anything, get the crude number:
java -Xlog:class+load:file=classes.log -jar app.jar
wc -l classes.log
A minimal Spring Boot web service loads somewhere in the region of eight to twelve thousand classes. An application with a full ORM, a message broker client, a metrics stack and a few cloud SDKs commonly loads twenty-five thousand or more. Each of those is read from a JAR, parsed, verified and linked, and none of that work depends on anything in your code.
If the count is large, the framework is not the problem — the dependency graph is. Grep the log for package prefixes and the biggest contributors will be obvious, and they are frequently libraries pulled in transitively for one method.
Ask the framework where the time went
Spring Boot has built-in startup instrumentation, and it is precise. Enable it by installing a buffering recorder:
public static void main(String[] args) {
SpringApplication app = new SpringApplication(Application.class);
app.setApplicationStartup(new BufferingApplicationStartup(4096));
app.run(args);
}
Then expose the endpoint:
management.endpoints.web.exposure.include=startup
A POST to /actuator/startup returns the recorded timeline once, with a duration for every step: each bean’s instantiation, each auto-configuration class’s evaluation, the web server’s initialisation. Sorted by duration, the list usually contains one or two entries that account for a third of the total, and they are rarely what anyone guessed.
Common finds, in rough order of frequency: an EntityManagerFactory scanning and validating a large entity model; a connection pool opening its minimum idle connections against a database in another availability zone; a client library performing service discovery during bean construction; a @PostConstruct method doing a network call that nobody documented as blocking.
The complementary report is condition evaluation. Starting with --debug prints which auto-configurations matched, which did not, and why:
java -jar app.jar --debug
That report is how you find out you are auto-configuring a cache, a batch infrastructure and two message listeners you never used.
Reduce what has to be discovered
Component scanning walks the classpath looking for annotated types. On a large application that is real time, and there are two ways to shorten it.
The narrower fix is to point @ComponentScan at specific packages rather than letting it default to everything below the application class. It costs nothing and it is the one to do first.
The older structural fix was the Spring context indexer, which generated META-INF/spring.components at compile time so the context could read a file instead of scanning the classpath. It is deprecated as of Spring Framework 6.1, in favour of the AOT engine and its generated components index, so a new application should not adopt it. If an existing build already has the dependency it still works; treat it as something to migrate away from rather than something to add.
The current answer for build-time discovery is Spring AOT, which is also what a native image build runs. It generates bean definitions ahead of time, which removes both the scan and a large part of the reflection that follows it.
Stop compiling code that runs once
A meaningful share of startup time is JIT compilation of code that executes exactly once. The compiler cannot know that, so it profiles and optimises framework initialisation paths as diligently as your hot request handler.
For a short-lived process — a batch job, a CLI, a function invocation — the direct answer is to stop before C2:
java -XX:TieredStopAtLevel=1 -jar app.jar
This limits compilation to C1: fast to compile, mediocre code, no profiling overhead. Startup improves measurably. Steady-state throughput gets meaningfully worse, so this is a flag for processes that exit, never for a long-running service.
For a long-running service, the equivalent is to skip the class-loading work instead. A class-data archive maps pre-parsed class metadata directly into memory:
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar
java -XX:SharedArchiveFile=app.jsa -jar app.jar
Run once to record, then start from the archive. This costs one build step, changes no application code, and removes a large fraction of the parse-and-verify time counted at the beginning of this article.
JDK 24 generalises the idea with the Project Leyden AOT cache (JEP 483): a training run records a configuration, a second step builds a cache, and subsequent starts get their classes already loaded and linked.
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -jar app.jar
java -XX:AOTCache=app.aot -jar app.jar
JDK 24’s cache holds classes only. JDK 25 extends it with ahead-of-time method profiling (JEP 515), which carries the profile data forward as well and so shortens warmup, not just startup.
The trades worth naming
Lazy initialisation (spring.main.lazy-initialization=true) defers bean creation until first use. It reliably shortens startup and reliably lengthens the first request that touches each bean. It also delays the failure of a misconfigured bean from startup to production traffic, which is a genuine loss — a context that fails to start is a deployment that never receives requests.
Native images through Spring AOT and GraalVM produce startup in tens of milliseconds and a much smaller resident footprint. The cost is a build that takes minutes, reflection that must be registered, dynamic behaviour that must be known at build time, and a different debugging and profiling toolchain.
Eager connection pools are worth keeping eager. Moving connection establishment out of startup means moving it into the first request, and a request that waits on a cold pool is a worse outcome than a start that takes another two hundred milliseconds.
The order that works: count the classes, read the startup endpoint, cut the dependencies and beans you are not using, then reach for an archive. Reaching for a native image before doing the first three usually means carrying the same waste into a much more expensive build.
Frequently asked
- Will lazy initialisation make my application start faster?
- It will make the startup log finish sooner. The work moves to whichever request first needs each bean, so cold-start latency improves and first-request latency degrades. For a serverless deployment measured on cold start that is a win; for a service behind a load balancer that receives traffic immediately, it usually is not.
- Is a native image the only way to get startup under a second?
- No. A class-data archive plus tiered compilation limits gets a typical service into the low seconds, and an AOT cache narrows the gap further. Native images go further still but change the deployment, the debugging story and the build pipeline.


