Bean Background Initialization in Spring Framework

Spring Boot startup times were driving me crazy, so I went deep on background bean initialization to see if it actually makes a difference. Spoiler: it does, but not without some gotchas you need to know about first. Here's what I found after testing it out.

A few months ago, during a particularly painful sprint where we were trying to get the order processing service to start up in under 10 seconds (it was sitting at around 23), a teammate of mine, Daniel Pacheco, who'd been deep in the Spring 6.2 release notes, dropped a comment in our Slack thread: "have you tried bootstrap = BACKGROUND on some of these beans?" I hadn't. I didn't even know that was a thing. Turns out it was exactly what we needed, at least for a chunk of the problem. What's Actually Going On With Spring Startup Spring's standard bean initialization is sequential. The container starts, scans for beans, resolves dependencies, creates and wires everything in order, and only then is the application context considered "ready." For small apps, that's fine. For a service with 300+ beans, some of which are doing real work during initialization (setting up connection pools, preloading caches, running schema validation), that sequence adds up fast. The thing is, not all beans actually depend on each other at initialization time. You might have a metrics registry, a read-only reference data cache, and a Kafka producer all initializing one after another, even though none of them care about the others being ready. They're just queued up because that's how the container works by default. Spring Framework 6.2 introduced a way to break that queue for specific beans. The @Bean(bootstrap = BACKGROUND) Annotation The feature itself is pretty simple on the surface. You annotate a @Bean method with bootstrap = BootstrapMode.BACKGROUND , and Spring will initialize that bean on a separate thread during context refresh, rather than blocking the main startup thread. import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import static org.springframework.context.annotation.BootstrapMode.BACKGROUND; @Configuration public class CacheConfig { @Bean(bootstrap = BACKGROUND) public ReferenceDataCache referenceDataCache(DataSource dataSource) { return new ReferenceDataCache(dataSource); // slow: hits the DB on init } } That's the basic usage. Spring sees the annotation, spins up the initialization on a background thread in the shared bootstrap executor, and moves on with the rest of the context setup. The part that surprised me, in a good way, is how Spring handles dependency safety. If another bean needs referenceDataCache during its own initialization, Spring will block that dependent bean's initialization thread until the background one finishes. It doesn't silently give you an incomplete bean or skip the wait. You get the background parallelism where it's safe, and you get the blocking where it's needed. Not going to lie, when I first read about this, I assumed there would be some gnarly race condition edge case waiting to bite us. We were careful to only apply it to beans without complex circular dependencies, but even on the messier ones, the framework held up. Setting Up the Background Executor For this to work, Spring needs an Executor bean registered under the name AbstractApplicationContext.BOOTSTRAP_EXECUTOR_BEAN_NAME . If you don't provide one, Spring falls back to synchronous initialization, so the annotation effectively becomes a no-op. Worth knowing before you spend time annotating beans and wonder why nothing changed. The classic platform-thread setup looks like this: import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.support.AbstractApplicationContext; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @Configuration public class BootstrapConfig { @Bean(name = AbstractApplicationContext.BOOTSTRAP_EXECUTOR_BEAN_NAME) public Executor bootstrapExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setThreadNamePrefix("ctx-bootstrap-"); executor.initialize(); return executor; } } That works fine, but if you're on Java 25, there's a cleaner option. Virtual threads have been a stable feature since Java 21, and by Java 25 they're the obvious default for anything I/O-bound. Bean initialization during startup is almost always I/O-bound, so the fit is natural. You swap the whole ThreadPoolTaskExecutor setup for a single line: import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.support.AbstractApplicationContext; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @Configuration public class BootstrapConfig { @Bean(name = AbstractApplicationContext.BOOTSTRAP_EXECUTOR_BEAN_NAME) public Executor bootstrapExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } } No pool sizing to tune, no core/max thread math. Each background bean gets its own virtual thread, and the JVM schedules them. The reason this is particularly good for startup is that most slow beans are slow because they're waiting on I/O: a database preload, a schema registry handshake, a remote config fetch. Virtual threads park during that wait instead of blocking a platform thread, so your other background beans keep moving. On our order processing service, switching from the ThreadPoolTaskExecutor setup to virtual threads knocked off another second or so during startup, mostly because several background beans were doing concurrent DB calls that had been quietly queuing for HikariCP connections. One tradeoff worth naming: you lose the named-thread visibility. With ThreadPoolTaskExecutor and a ctx-bootstrap- prefix, thread dumps during startup are immediately readable. With virtual threads, most profilers don't give you that same clarity. If you're actively debugging a startup hang, temporarily switching back to the platform-thread executor makes the thread dump a lot easier to interpret. It's a minor thing, but I've been caught out by it. If you want the best of both worlds during development, you can make the executor conditional: import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.context.support.AbstractApplicationContext; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @Configuration public class BootstrapConfig { @Bean(name = AbstractApplicationContext.BOOTSTRAP_EXECUTOR_BEAN_NAME) @Profile("!dev") public Executor bootstrapExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); } @Bean(name = AbstractApplicationContext.BOOTSTRAP_EXECUTOR_BEAN_NAME) @Profile("dev") public Executor bootstrapExecutorDev() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setThreadNamePrefix("ctx-bootstrap-"); executor.initialize(); return executor; } } Overkill for most teams, but we added something similar after a particularly confusing morning trying to diagnose a startup delay in our dev environment. Named threads in dev, virtual threads in production. What's Actually Safe to Mark as BACKGROUND This is where you have to think, not just spray the annotation everywhere. Good candidates: Beans that do I/O during initialization. Things like caches that preload from a database, clients that fetch remote configuration, or connection pools that eagerly validate connections at startup. These benefit the most, especially with virtual threads absorbing the blocking waits. Beans with no dependents early in the startup chain. If nothing needs this bean until a...