What startup time actually means and why it matters
"Startup time" covers at least four different numbers, and teams often optimize the wrong one.
- JVM launch: process start, class loading, and runtime initialization before
mainruns any Spring code. - Context refresh: Spring creates bean definitions, runs auto-configuration, instantiates singletons, and fires lifecycle callbacks.
- Readiness: the application reports it can accept traffic, which is what the orchestrator acts on.
- Time to first served request: a real request succeeds at acceptable latency. Lazy beans, cold connection pools, and interpreted code that has not yet been JIT-compiled all add cost here.
The familiar "Started ... in X seconds" log line covers only part of this. It does not include image pull, container scheduling, the delay between the process becoming ready and the readiness probe noticing, or the slow first requests while caches and the JIT warm up. A service that logs a fast start can still take much longer to serve useful traffic.
These gaps matter to backend and platform engineers in four situations:
- Autoscaling: new capacity arrives after the spike has already hit.
- Rolling deployments: total rollout time is roughly per-pod startup multiplied by the number of batches.
- Crash recovery: the outage lasts at least as long as a restart.
- Test suites: each new application context pays refresh cost again.
Not all startup cost should be cut. Validating configuration, checking connectivity to required dependencies, and failing fast on bad credentials are worth the time they take. A pod that boots quickly and then fails on its first request is worse than one that refuses to become ready.
time ------------------------------------------------------------------->
+-----------+ +-----------+ +-----------+ +-----------+ +-----------+
|image pull | |JVM launch | |context | |readiness | |first real |
|container |->|class load |->|refresh: |->|reported, |->|request: |
|scheduling | |runtime | |beans, auto| |probe sees | |JIT, pools,|
| | |init | |config, | |it | |lazy beans |
| | | | |fail-fast* | | | | |
+-----------+ +-----------+ +-----------+ +-----------+ +-----------+
|<--------->|
"Started ... in X s"
|<------------------- time to first useful request -------------------->|
the full path, not the log line, is paid again in:
+--------------+ +--------------+ +--------------+ +--------------+
| autoscaling | | rolling | | crash | | test suites |
| capacity late| | x batches | | recovery | | each context |
+--------------+ +--------------+ +--------------+ +--------------+
* worth keeping: validate config and dependencies, fail before ready
Startup is a chain of phases from container scheduling to a warmed-up first request, and the familiar "Started ... in X seconds" log line covers only part of it, while autoscaling, rollouts, crash recovery and test suites all pay the full path.
How to record the startup timeline with ApplicationStartup and the startup actuator
Before tuning any of that, you need a timeline. Spring Framework records startup steps through the ApplicationStartup interface, but the default implementation discards them. To keep them, set a BufferingApplicationStartup on SpringApplication before calling run(). Its constructor takes a capacity, which is the maximum number of steps it stores. When the buffer fills, it drops later steps rather than growing, so memory stays bounded. Size the capacity to your context: a few thousand covers most services, and a large monolith may need more.
Next, add the actuator and include startup in management.endpoints.web.exposure.include. A GET to /actuator/startup returns the recorded steps. A POST returns them and clears the buffer, which releases the memory once you have the data.
Each event has a step name such as spring.beans.instantiate or spring.context.refresh, an ID, a parent ID, tags such as beanName, and a duration. Durations include child steps. A bean that looks slow may only be waiting on its dependencies, so subtract the children's durations to get each step's self time before sorting. Then sort by self time. Slow context phases, like bean post-processing or refresh, show up near the top, next to individual slow beans.
The endpoint has costs:
- Information exposure. The timeline shows bean names, class structure and auto-configuration choices. Expose it only on the management port, behind authentication, or on internal networks.
- Noise. Use
addFilterto record only the steps you care about, such as bean instantiation. - Duration. Treat it as a diagnostic tool. Remove the exposure and revert to the default
ApplicationStartuponce you have your answer.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(OrderServiceApplication.class);
// Capacity is the maximum number of steps kept. Once the buffer is full,
// later steps are dropped, so memory stays bounded.
BufferingApplicationStartup startup = new BufferingApplicationStartup(4096);
// Optional: record only bean instantiation steps, which cuts noise and buffer use.
// Leave this line out if you also want context phases such as spring.context.refresh.
startup.addFilter(step -> step.getName().startsWith("spring.beans.instantiate"));
// Set this before run(). The default ApplicationStartup discards all steps.
app.setApplicationStartup(startup);
app.run(args);
}
}
// application.properties (diagnosis only; remove after you have your answer):
// management.endpoints.web.exposure.include=health,startup
// management.server.port=8081 # keep the endpoint off the public port
//
// GET /actuator/startup returns the timeline
// POST /actuator/startup returns the timeline and clears the buffer
Shows how to configure a bounded, filtered BufferingApplicationStartup before run() and expose the startup actuator endpoint on a separate management port for temporary diagnosis.
Bean profiling: finding which beans are slow and why
A spring.beans.instantiate step's duration covers more than the bean itself. Spring creates dependencies on demand while it builds the requesting bean, so those dependency steps are nested inside it. A controller that shows 4 seconds may spend almost all of that waiting for a repository's DataSource. To find a bean's own cost, subtract the durations of its direct children, which you can identify by parentId, from its total. Rank beans by this self time, not by raw duration. Otherwise you will optimize the wrong layer.
Common culprits
The slowest self times usually come from work that blocks on something outside the JVM:
- Eager connection pools. These open and validate connections, so DNS lookups and TLS handshakes run before the context is ready.
- Schema migrations that run on boot. These lock and scan the database on every instance.
- Cache warm-up that loads full datasets synchronously.
- Remote calls in constructors or
@PostConstruct. These include fetching config, tokens or feature flags. A slow dependency then makes startup slow too. If the dependency is unavailable, startup can fail.
Auditing auto-configuration
Some cost comes from beans you never asked for. Start the app with --debug (or debug=true) to print the condition evaluation report. It lists which auto-configurations matched and which did not, and why. Every positive match brings in beans, and some of them open resources. Starters you no longer use are a frequent source of these matches. Remove the unused starters first. Then narrow overly broad @ComponentScan base packages, because scanning large packages costs classpath scanning time and can register beans that were never meant to run in this service.
# Rank beans by self time from the Actuator startup endpoint.
# Prereqs: app built with BufferingApplicationStartup and the 'startup' endpoint exposed.
# curl -s > startup.json
# python3 bean_self_time.py startup.json
import json, re, sys
from collections import defaultdict
def seconds(iso):
# Durations are ISO-8601, e.g. "PT0.042S" or "PT1M3.5S"
h, m, s = re.fullmatch(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?", iso).groups()
return int(h or 0) * 3600 + int(m or 0) * 60 + float(s or 0)
events = json.load(open(sys.argv[1]))["timeline"]["events"]
total, bean = {}, {}
children = defaultdict(float) # sum of direct children's durations per parent id
for e in events:
step = e["startupStep"]
d = seconds(e["duration"])
total[step["id"]] = d
if step.get("parentId") is not None:
children[step["parentId"]] += d
if step["name"] == "spring.beans.instantiate":
tags = {t["key"]: t["value"] for t in step.get("tags", [])}
bean[step["id"]] = tags.get("beanName", "?")
# Self time = own duration minus time spent creating nested dependencies
rows = sorted(
((total[i] - children[i], total[i], name) for i, name in bean.items()),
reverse=True,
)
print(f"{'self(s)':>8} {'total(s)':>9} bean")
for self_t, tot, name in rows[:20]:
print(f"{self_t:8.3f} {tot:9.3f} {name}")
A script that reads the /actuator/startup timeline, subtracts each bean's direct children (matched by parentId) from its duration, and lists the beans with the highest self time.
Profiling below Spring with JFR and sampling profilers
Bean steps tell you which bean was slow. They do not tell you why. If a step shows that a configuration class took most of the boot window but its own code does almost nothing, the time is going somewhere Spring doesn't instrument: class loading, reflection, classpath scanning internals, or a network call hidden inside a library constructor.
To see that layer, record the JVM itself from process start. Java Flight Recorder can be enabled at launch and stopped once the application reports ready, so the recording covers exactly the boot window. A sampling profiler attached as a startup agent does the same job and often gives clearer flame graphs. Both must start with the JVM. Attaching after boot captures nothing useful.
When you read the result, look for these patterns:
- Wide frames under resource and class-path lookups. These point to scanning jar contents. Large or numerous jars make this worse.
- Heavy class-loading and class-definition activity. This usually comes from big dependency trees, where each class is loaded, verified, and linked once.
- Reflection and proxy generation. These cluster around annotation processing and bean post-processing.
- Threads parked in socket connect or address resolution. This is the classic hidden cost. A client resolves a hostname or opens a connection during construction, and a slow DNS server or an unreachable host shows up as wall-clock time with almost no CPU.
The last pattern is why you should view samples in wall-clock mode rather than CPU-only mode. A CPU profile makes blocking I/O look free.
#!/usr/bin/env bash
# Record a JFR session covering only the boot window, then stop it once Spring reports ready.
set -euo pipefail
APP_JAR=build/libs/orders-service.jar # placeholder
OUT=boot.jfr
LOG=boot.log
# Start recording at JVM launch so class loading and early init are captured.
# settings=profile samples more often than the default settings.
java -XX:StartFlightRecording=name=boot,settings=profile,filename="$OUT" \
-jar "$APP_JAR" > "$LOG" 2>&1 &
PID=$!
# Wait for Spring Boot's standard 'Started <App> in N seconds' log line.
until grep -q 'Started .* in .* seconds' "$LOG"; do
if ! kill -0 "$PID" 2>/dev/null; then echo 'app exited during boot' >&2; exit 1; fi
sleep 0.5
done
# Stop the recording and write it out while the app keeps running.
jcmd "$PID" JFR.stop name=boot filename="$OUT"
# Quick overview of which event types were recorded.
jfr summary "$OUT"
# Dump sampled stacks; look for wide resource lookups, class definition,
# reflection, and threads parked in address resolution or socket connect.
jfr print --events jdk.ExecutionSample "$OUT" > samples.txt
# Open boot.jfr in JDK Mission Control for flame graphs and thread state views.
kill "$PID"
Starts a JFR recording at JVM launch, stops it when Spring logs that startup finished, and dumps sampled stacks so the boot window can be inspected.
Fixing what the data shows: blocking I/O, auto-configuration exclusions, and test contexts
Most slow boots trace back to a few causes. Each fix is cheap, but each moves work or responsibility somewhere else.
Move blocking I/O out of the critical path
Schema migrations. Running Flyway or Liquibase on every boot makes each replica wait on database locks it rarely needs. Run migrations as a separate pipeline step or one-off job, then disable them in the application with spring.flyway.enabled=false or spring.liquibase.enabled=false. Boot becomes faster and predictable. The trade-off is that the pipeline must now ensure the schema is current before new code starts.
Remote config and secret fetches. Give every fetch an explicit connect and read timeout, and decide whether failure stops the boot or falls back to a safe default. An unbounded call to a slow endpoint is the wall-clock gap you saw in the profiler.
Cache warm-up and broker connections. Start these on a background executor instead of in @PostConstruct. Keep readiness accurate by publishing ReadinessState.REFUSING_TRAFFIC until the work finishes. Otherwise you have moved the slowness into user requests.
Exclude auto-configurations you don't use
Use spring.autoconfigure.exclude to remove configurations pulled in by transitive dependencies. This makes behavior less obvious, because a later dependency upgrade may expect a configuration you removed. Record why each exclusion exists.
Reuse the test context cache
Spring caches test contexts by configuration. Every new @MockBean, profile, or property override creates a separate context that must boot again. Use slices such as @WebMvcTest and @DataJpaTest, and standardize test configuration in shared base classes so tests can reuse cached contexts.
When to avoid lazy initialization and other alternatives
spring.main.lazy-initialization=true often shrinks the startup timeline, but it does not remove work. It moves bean creation from boot to the first request that needs each bean. That trade has three costs. Misconfiguration errors, such as a missing property or a bad connection string, surface in production traffic instead of failing the deploy. The first requests pay for bean creation, connection pools and JIT warm-up, so tail latency spikes after every rollout. Readiness probes can report healthy before the expensive beans exist, so the orchestrator routes traffic to an instance that is not yet ready to serve it.
A better default is targeted @Lazy on beans the startup data shows are slow and rarely used, such as an admin-only report generator. Keep eager initialization for anything on the request path.
Other options attack different costs. Measure each one on your own workload:
| Option | What it reduces | Main cost |
|---|---|---|
Targeted @Lazy |
Creation of specific rarely used beans | Deferred failures for those beans |
| Class data sharing | Class loading and verification | Archive build step that must match the classpath |
| Limiting tiered compilation | JIT overhead during boot | Lower peak throughput |
| Raising container CPU limits | Contention during parallel class loading and JIT | Higher cloud spend per instance |
| AOT processing | Runtime bean-definition work | Longer builds and fixed conditions at build time |
| Native image | Most JVM startup and warm-up | Long builds, reflection and proxy configuration, library gaps, often lower peak throughput |
Building a repeatable measurement loop and its impact on cost and engineering time
Treat a startup number like any other performance measurement: one run tells you almost nothing. Run several cold starts (for example, five to ten) on hardware and images that match production, and compare medians rather than single results or averages, because one bad run can skew an average. Each cold start should really be cold. A warm OS page cache, an image already pulled to the node, or a neighbour pod competing for CPU can each change the result more than the fix you are testing. Change one thing at a time so you can attribute each change in the median to a specific fix. Once you have a baseline, add a CI check that boots the application and fails when the median exceeds a budget. That catches slow additions, such as a new auto-configuration, when they are introduced rather than months later.
Illustrative estimate (assumed numbers, not a benchmark)
Formula: daily boot time = median cold start × starts per day
| Input | Before | After |
|---|---|---|
| Median cold start (assumed) | 40 s | 22 s |
| Starts per day across CI, rollouts and scaling (assumed) | 300 | 300 |
| Daily boot time | 12,000 s (200 min) | 6,600 s (110 min) |
Under these assumptions, the team saves 90 minutes of compute and waiting per day. Substitute your own medians and start counts to get your figure.
Measuring first also saves engineering time. Enabling a global flag such as lazy initialization can move cost to the first request and hide failures. A profile identifies the two or three beans that actually cause the delay. Faster boots shorten test feedback loops and rolling deployments. They also reduce the extra capacity you must keep running while new instances start and pass readiness checks.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring boot slow startup, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.