Every second your app takes to start is a second someone's staring at a blank deploy log. Spring Boot 4.1 finally gives us real visibility into what's happening during startup, not just guesses. I dug into Application Startup Tracking and it changes how I debug slow boots.
A few months back, one of our services on Code Training Lab started taking almost 9 seconds to boot in a container with a startup probe timeout of 10. It wasn't failing yet, but it was close enough that I started sweating every deploy. We'd just moved that codebase to Java 26 and Spring Boot 4.1, so my first instinct was to blame the migration: too many beans, some autoconfiguration doing something dumb, maybe a slow database connection pool warmup. Turns out I was half right, but guessing wasn't going to cut it. That's when I sat down and actually used ApplicationStartup properly instead of just knowing it existed. If you've been doing Spring long enough, you've probably heard of ApplicationStartup and StartupStep and nodded along without ever touching them. I was in that camp for a while too. It felt like one of those features that's nice in theory but you never quite have a reason to reach for, until you do, and then it's genuinely the fastest way to answer "where is my app spending time on boot." The problem with guessing Before Spring gave us proper startup instrumentation, the standard move was adding @PostConstruct timers everywhere, sprinkling System.currentTimeMillis() calls around bean initialization, or cranking DEBUG logging on org.springframework and scrolling through a wall of text hoping something jumps out. I've done all three on different projects over the years. None of them are great. The logging approach is the worst offender. You get thousands of lines, most irrelevant, and you're stuck grepping timestamps and correlating log lines that don't even reference each other. It works, kind of, the same way finding a needle in a haystack "works" if you're patient enough. I don't have that kind of patience anymore. Manual timers are better but they don't scale. You end up instrumenting the ten beans you suspect are slow, and the actual bottleneck is bean number eleven you didn't think to check. Spring's own startup sequence, context refresh, bean instantiation, autoconfiguration condition evaluation, is mostly invisible unless you go looking for it with the right tool. What ApplicationStartup actually gives you ApplicationStartup is the interface Spring uses internally to record startup steps. Think of it as a structured, hierarchical timeline of everything that happens during context initialization, rather than a flat log stream. Spring already instruments dozens of steps out of the box: bean definition registration, bean instantiation, autoconfiguration class conditions. You don't add any of that yourself. You just plug in an implementation that captures it. This part of the core is Spring Framework territory, and it came through the Spring Framework 7 upgrade underneath Boot 4.1 essentially unchanged, which was a relief given how much else moved. There are three implementations worth knowing, and they solve different problems. BufferingApplicationStartup keeps everything in memory and lets you inspect it programmatically or dump it out, typically through the Actuator startup endpoint. This is the one I reach for during local development and debugging sessions: low friction, curl the endpoint, get JSON back. FlightRecorderApplicationStartup writes startup events into JDK Flight Recorder, so you can open the resulting .jfr file in JDK Mission Control and get a visual, flame-graph-style timeline. This is the one for production or for correlating startup timing with other JVM-level events: GC pauses, class loading, thread activity. It's been built into the JDK since Java 11, no extra dependency needed, and it plays even nicer now that we're running Java 26. Custom implementations let you route startup events wherever you want: a metrics backend, a custom log sink, whatever fits your observability stack. I haven't needed to write one myself yet, but the extension point is there if the two built-in options don't fit. Wiring it up Here's the part that surprised me: it's almost aggressively simple to enable. You set it before the application context starts, in main . @SpringBootApplication public class TrainingLabApplication { public static void main(String[] args) { SpringApplication app = new SpringApplication(TrainingLabApplication.class); app.setApplicationStartup(new BufferingApplicationStartup(2048)); app.run(args); } } The 2048 is the buffer capacity: how many startup steps it holds before dropping the oldest ones. I bumped this to 4096 on our service because the default felt tight once autoconfiguration steps got counted in. Not a huge deal either way, just something to watch once you have more than a handful of starters on the classpath. And if you're running Spring Boot with a handful of starters, you have more auto-configured beans than you think. To see the data, expose the Actuator startup endpoint: management: endpoints: web: exposure: include: startup Then hit it after the app boots: curl -X POST http://localhost:8082/actuator/startup Yes, it's a POST, not a GET, which tripped me up the first time because every other Actuator endpoint I use is a GET. Spring designed it that way because reading the buffer also drains it, so it's treated as a state-changing operation. Fine once you know it, mildly annoying the first time you don't. The response is a JSON tree of startup steps with timestamps, durations, and parent-child relationships, something like this, trimmed down: { "timeline": { "startTime": "2025-11-04T14:22:01.104Z", "events": [ { "startTime": "2025-11-04T14:22:01.180Z", "endTime": "2025-11-04T14:22:01.340Z", "duration": "PT0.16S", "startupStep": { "name": "spring.beans.instantiate", "id": 47, "tags": [ { "key": "beanName", "value": "challengeGradingService" } ] } } ] } } For our challengeGradingService bean specifically, this was how I found out it was 160ms just to instantiate, and that number alone told me nothing was catastrophically wrong there. The real culprit was somewhere else entirely, which I'll get to. Flight Recorder, for the bigger picture BufferingApplicationStartup is great for a quick look, but when I wanted to correlate startup timing against actual JVM behavior (class loading time, JIT compilation, thread contention during startup), Flight Recorder was the better tool. public static void main(String[] args) { SpringApplication app = new SpringApplication(TrainingLabApplication.class); app.setApplicationStartup(new FlightRecorderApplicationStartup()); app.run(args); } Then run the JVM with recording enabled: java -XX:StartFlightRecording=filename=startup.jfr,duration=60s \ -jar training-lab.jar Open the resulting .jfr in JDK Mission Control and you get a genuinely useful visual timeline. This is where I found the actual problem behind that 9-second boot: it wasn't bean instantiation at all. It was Liquibase running changelog validation against a slow connection to our staging PostgreSQL instance, something that never showed up cleanly in the BufferingApplicationStartup JSON because it wasn't tagged as a distinct Spring startup step at the time. Flight Recorder showed a large chunk of wall-clock time sitting inside a JDBC call, and that pointed me straight at it. Lesson learned: the tool that instruments Spring's own lifecycle isn't always the tool that catches infrastructure-adjacent slowness. You need both, and that held true whether the service was still on Spring Boot 3.5 or already on 4.1. Writing your own startup steps This is the part most people skip, and it's the part I now use the most. You can record custom startup steps for code that runs during context initialization, not just rely on what Spring instruments automatically. @Component public class ChallengeCatalogWarmup implements ApplicationListener<ContextRefreshedEvent> { private final ApplicationStartup applicationStartup; pr...