Performance Testing Java Application Startup Time With Spring Boot
Application startup time matters whenever a Java service is restarted, scaled, deployed, or recovered. A slow Spring Boot application can delay a release, increase Kubernetes rollout time, and leave users waiting while a backend becomes ready. For serverless or short-lived workloads, startup latency can also affect every request that creates a fresh instance.
Startup performance is more complicated than measuring the time printed between two log lines. The result can include operating system process creation, JVM bootstrapping, class loading, Spring context creation, database connection attempts, migrations, and readiness checks. A useful test separates these stages so that an optimisation targets the actual bottleneck.
This is especially relevant for teams running services across Australian regions, where an application may serve customers in Sydney, Melbourne, Brisbane, or Perth while deploying to a cloud region with a different network path. A few hundred milliseconds spent waiting for a remote dependency can become a much larger operational problem during scaling or failover.
Define What Startup Time Means
A practical startup test needs a precise start and finish boundary. The start can be the moment the Java process is launched. The finish might be the first successful health response, the ApplicationReadyEvent, or the point at which the service can process a representative request. These events describe different aspects of performance.
Spring Boot emits an ApplicationStartedEvent after the application context has refreshed and an ApplicationReadyEvent after runners have completed. The latter is often a sensible application-level boundary, but it does not automatically prove that every external dependency is usable. A service might be ready from Spring’s perspective while its first database query still triggers connection-pool initialisation.
For operational testing, measure at least three values:
- Process launch to
ApplicationStartedEvent - Process launch to
ApplicationReadyEvent - Process launch to a successful health or business endpoint
Record the timestamp with a monotonic clock where possible. Wall-clock time can jump because of NTP adjustments or daylight-saving changes. The application logs can provide event timestamps, while an external tool measures the complete process lifetime from outside the JVM.
A startup test should also state whether it covers a cold JVM, a warm operating system page cache, a reused container image, or a production-like deployment. These conditions can produce substantially different results.
Build A Repeatable Test Harness
The test environment should be controlled before changing application code. Use the same Java version, Spring Boot version, CPU limits, memory limits, container image, configuration, and dependency versions for every comparison. Run enough repetitions to expose variation rather than relying on a single fast run.
A simple shell harness can launch the executable JAR, wait for a health endpoint, and record elapsed time:
#!/usr/bin/env bash
set -euo pipefail
PORT=8080
START=$(date +%s%N)
java -jar build/libs/orders.jar \
--server.port=$PORT \
> /tmp/orders-startup.log 2>&1 &
PID=$!
cleanup() {
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
}
trap cleanup EXIT
until curl --silent --fail "http://localhost:$PORT/actuator/health/readiness" \
>/dev/null; do
sleep 0.05
done
END=$(date +%s%N)
echo "$(( (END - START) / 1000000 )) ms"
For better statistics, execute the harness repeatedly and calculate median, p90, p95, minimum, and maximum. Tools such as hyperfine can launch a command many times, though the command must terminate cleanly after the readiness event. Container orchestration tests should measure pod creation to readiness as a separate scenario because image pulling, scheduling, and networking are outside JVM startup.
A developer laptop in Sydney may have a different filesystem and CPU profile from a small cloud instance in Melbourne. Run local tests for fast feedback, then repeat them in the deployment environment. Include realistic CPU throttling and memory limits; an unconstrained workstation can hide production behaviour.
Identify Spring Boot Startup Work
Spring Boot spends startup time creating the application context, scanning classes, resolving configuration, instantiating beans, applying post-processors, and starting embedded infrastructure. Auto-configuration makes this convenient, yet an unused starter can still add condition evaluation, classpath scanning, and bean definitions.
Spring Boot’s startup instrumentation can show where time is spent. Configure an ApplicationStartup implementation in the application:
@SpringBootApplication
public class OrdersApplication {
public static void main(String[] args) {
SpringApplication application =
new SpringApplication(OrdersApplication.class);
application.setApplicationStartup(
new BufferingApplicationStartup(2048));
application.run(args);
}
}
The actuator startup endpoint can then expose startup steps when the relevant endpoint is enabled. FlightRecorderApplicationStartup is useful for deeper investigations because Java Flight Recorder can correlate Spring events with JVM activity, class loading, allocation, and garbage collection.
Look for long bean creation steps, repeated configuration binding, repository initialisation, entity scanning, and network calls made during bean construction. A constructor that invokes an HTTP client, validates a remote schema, or executes a database query turns an application startup phase into a dependency availability test. Moving that work to an explicit readiness phase or lazy operation often makes the lifecycle easier to reason about.
Spring’s condition evaluation report can reveal unexpected auto-configuration. Remove starters that are not needed, narrow component scanning, and avoid creating multiple clients or connection pools for the same service. These changes should be validated with functional tests because a faster context can still be incorrectly configured.
Compare Startup Strategies
Different Java startup approaches solve different problems. A regular JVM with a class data sharing archive is usually the least disruptive option. Lazy bean initialisation can reduce initial work, but it moves latency to the first request. A native image can deliver very fast startup, although it introduces build constraints and may require runtime hints.
| Approach | Typical Strength | Main Cost | Suitable Measurement |
|---|---|---|---|
| Standard JVM | Broad compatibility and simple builds | Slower class loading and context creation | Process to readiness |
| JVM with CDS or AppCDS | Reduced class loading overhead | Archive maintenance and environment matching | Cold and warm process runs |
| Lazy initialisation | Smaller initial context workload | First-use latency and deferred failures | Startup plus first representative request |
| Spring AOT on the JVM | Less runtime discovery | Additional build configuration | Startup and memory under production flags |
| GraalVM native image | Very fast startup and low memory use | Longer builds, hints, and compatibility review | Binary launch to health and business readiness |
Class Data Sharing can help when the same classes are loaded repeatedly, such as in autoscaled containers. Its value depends on the Java version, archive generation, image layout, and workload. Measure it in the same container base image used in production rather than assuming that a local archive will behave identically.
Native compilation should be assessed as a separate delivery path, not as a JVM tuning switch. Reflection, dynamic proxies, resource loading, and some testing libraries may need configuration. Spring Boot’s AOT support and native build tooling simplify many common cases, but integration tests must still cover security, persistence, messaging, and observability features.
The comparison should include more than elapsed startup time. Track peak resident memory, image size, build duration, CPU usage, first-request latency, and failure behaviour. A 200-millisecond improvement may be unattractive if it causes a large increase in build complexity or makes emergency debugging harder.
Test Cold Starts And Dependency Behaviour
Cold-start testing clears assumptions about cached classes and already-running infrastructure. Stop the process between runs, vary the filesystem cache when the operating system permits it, and use a fresh container where appropriate. Do not clear caches casually on a shared machine, as this can affect other workloads and requires suitable privileges.
Run two categories of tests. The first uses local or containerised dependencies with predictable response times. The second uses production-like services such as PostgreSQL, Redis, Kafka, or an HTTP dependency. This distinction shows whether the application itself is slow or whether startup is blocked by the environment.
Network location matters in Australia. A service deployed in Sydney that connects to a database in Melbourne may behave differently from one using a same-region database. A Perth workload can expose higher latency when a dependency is hosted on the east coast. Tests should therefore record region, availability zone, DNS behaviour, TLS setup, and connection-pool configuration.
Avoid making startup depend on every optional service. Health groups can distinguish liveness from readiness, while retry and timeout policies prevent one unavailable integration from hanging the entire process indefinitely. If a business requirement demands dependency validation before readiness, measure that deliberately and report the dependency contribution separately.
Database migrations deserve their own measurement. Running Flyway or Liquibase during every replica startup can dominate results and create contention during a rollout. In a production-like test, record migration duration, lock wait time, and the effect of starting several replicas simultaneously.
Analyse Results Beyond The Average
Startup distributions are often skewed. Most runs may finish in three seconds while a few take ten seconds because of JIT compilation, garbage collection, filesystem delays, DNS resolution, or a slow dependency. The average conceals this behaviour, so report percentiles and retain raw samples.
A useful result record includes:
- Git commit, Java version, Spring Boot version, and active profiles
- CPU and memory limits
- Container image digest and deployment region
- Process-to-ready, context refresh, and first-request times
- Median, p90, p95, and maximum values
- Peak memory, CPU time, and garbage-collection events
- Dependency and migration durations
Use a representative endpoint for the first-request test. A health endpoint may execute almost no application code and can make lazy initialisation appear free. An authenticated read operation or a small write flow provides a more realistic view of deferred bean creation, database pool setup, serialisation, and security configuration.
Performance regression thresholds should reflect operational impact. For example, a team might fail a build when p95 process-to-ready time increases by more than 15 percent, while allowing a small change in the median. Keep a baseline artifact so that a result can be compared with the last release rather than with an unstable local run.
Java Flight Recorder, async-profiler, class-loading logs, and container metrics help explain a regression. A longer startup caused by class loading needs a different fix from one caused by a 1.5-second DNS timeout. Measurements should lead to a hypothesis, a targeted change, and a repeatable retest.
Turn Measurements Into Reliable Improvements
Start with high-confidence changes. Remove unnecessary starters, reduce broad component scanning, eliminate network calls from constructors, and configure explicit timeouts. Check whether observability, messaging, security, and persistence modules are all required by the service rather than inherited from a shared template.
Lazy initialisation can be useful for command-line tools or services with many rarely used features. It is risky when readiness is expected to mean that the first real request will be fast. If it is enabled, add a warm-up test that invokes important code paths before traffic is accepted, or accept the first-request cost as part of the service contract.
For continuously deployed applications, startup testing belongs in the delivery pipeline. Build the same artifact used in staging, start it with production-like flags, probe readiness, and publish the timing distribution. A Kubernetes rollout should also test several replicas because simultaneous startup can overload a database or configuration service even when a single instance looks healthy.
The strongest benchmark is repeatable, tied to a clear lifecycle event, and connected to user or operational impact. Measure the JVM, Spring context, dependencies, readiness, and first request as related but distinct stages. The key point to remember is that fast Spring Boot startup comes from removing and controlling work, then proving the result under the same constraints in which the application will actually run.