The refresh() Sequence and Extension Point Timing
The refresh() Sequence and Extension Point Timing
The Annotation (or Abstraction)
Engineers reach for @PostConstruct, ApplicationRunner, @EventListener(ApplicationReadyEvent.class), and SmartInitializingSingleton when they need something to happen at startup. The common belief is that these all fire “after Spring starts.” The actual timing differences between them determine whether your initialization code can depend on other beans, whether the web server is accepting requests, and whether the application has fully loaded.
The Mechanism
AbstractApplicationContext.refresh() has thirteen steps. Each extension point fires at a specific step. Getting the timing wrong means your initialization code runs before its dependencies exist, or after the application starts accepting traffic.
The full sequence, annotated with extension point firing:
| Step | Method | What fires here |
|---|---|---|
| 1 | prepareRefresh() | Nothing. Sets active flag, validates required properties. |
| 2 | obtainFreshBeanFactory() | Bean definitions loaded (XML, annotation scan, import). |
| 3 | prepareBeanFactory(bf) | Default beans registered: environment, systemProperties. |
| 4 | postProcessBeanFactory(bf) | Subclass hook. Web contexts register web-specific scopes here. |
| 5 | invokeBeanFactoryPostProcessors(bf) | BeanDefinitionRegistryPostProcessor, then BeanFactoryPostProcessor. |
| 6 | registerBeanPostProcessors(bf) | BeanPostProcessors registered (not invoked). |
| 7 | initMessageSource() | MessageSource bean initialized. |
| 8 | initApplicationEventMulticaster() | Event multicaster ready. |
| 9 | onRefresh() | Embedded web server created and started. |
| 10 | registerListeners() | ApplicationListener beans registered with multicaster. |
| 11 | finishBeanFactoryInitialization(bf) | All non-lazy singletons instantiated. BeanPostProcessors fire on each bean. @PostConstruct fires here. SmartInitializingSingleton.afterSingletonsInstantiated() fires after all singletons are done. |
| 12 | finishRefresh() | ContextRefreshedEvent published. Lifecycle processors started. |
After refresh() returns, SpringApplication fires ApplicationStartedEvent, calls ApplicationRunner and CommandLineRunner beans, then fires ApplicationReadyEvent.
The Debuggable Demonstration
This component proves the ordering by printing timestamps at each hook:
// CORRECT: Demonstrates exact firing order of all startup hooks
@Component
public class StartupTimingProbe implements
BeanPostProcessor,
SmartInitializingSingleton,
ApplicationRunner,
ApplicationListener<ContextRefreshedEvent> {
private static final Logger log = LoggerFactory.getLogger(StartupTimingProbe.class);
@PostConstruct
public void postConstruct() {
log.info("[1] @PostConstruct fired - inside finishBeanFactoryInitialization");
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (beanName.equals("orderService")) {
log.info("[2] BeanPostProcessor.postProcessAfterInitialization for orderService");
}
return bean;
}
@Override
public void afterSingletonsInstantiated() {
log.info("[3] SmartInitializingSingleton - all singletons created");
}
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
log.info("[4] ContextRefreshedEvent - refresh() finishing");
}
@Override
public void run(ApplicationArguments args) {
log.info("[5] ApplicationRunner - after refresh(), before ApplicationReadyEvent");
}
}
Output:
[1] @PostConstruct fired - inside finishBeanFactoryInitialization
[2] BeanPostProcessor.postProcessAfterInitialization for orderService
[3] SmartInitializingSingleton - all singletons created
[4] ContextRefreshedEvent - refresh() finishing
[5] ApplicationRunner - after refresh(), before ApplicationReadyEvent
The ordering is absolute. @PostConstruct fires as part of individual bean initialization. SmartInitializingSingleton fires after every singleton is done. ContextRefreshedEvent fires at the end of refresh(). ApplicationRunner fires after refresh() returns.
The Failure Mode
The SaaS backend needs to pre-load tenant configurations from the database at startup. The team uses @PostConstruct:
// BROKEN: @PostConstruct fires during bean creation.
// Other beans that depend on tenant config may not exist yet.
@Component
public class TenantConfigLoader {
private final TenantRepository tenantRepository;
private final CacheManager cacheManager; // May not be fully initialized
@PostConstruct
public void loadTenants() {
// CacheManager's @PostConstruct may not have fired yet
// if this bean is instantiated first
List<Tenant> tenants = tenantRepository.findAll();
Cache cache = cacheManager.getCache("tenants"); // NPE: cache not initialized
tenants.forEach(t -> cache.put(t.getId(), t));
}
}
@PostConstruct fires during finishBeanFactoryInitialization, as each bean is created. The order of bean creation within that phase depends on dependency resolution order. If TenantConfigLoader is created before CacheManager completes its own @PostConstruct, the cache is not ready.
The Correct Pattern
// CORRECT: Use SmartInitializingSingleton to guarantee
// all singletons (including CacheManager) are fully initialized.
@Component
public class TenantConfigLoader implements SmartInitializingSingleton {
private final TenantRepository tenantRepository;
private final CacheManager cacheManager;
public TenantConfigLoader(TenantRepository tenantRepository,
CacheManager cacheManager) {
this.tenantRepository = tenantRepository;
this.cacheManager = cacheManager;
}
@Override
public void afterSingletonsInstantiated() {
List<Tenant> tenants = tenantRepository.findAll();
Cache cache = cacheManager.getCache("tenants");
tenants.forEach(t -> cache.put(t.getId(), t));
}
}
SmartInitializingSingleton.afterSingletonsInstantiated() fires after every singleton bean has been created and fully initialized, including their @PostConstruct methods. This is the correct hook for cross-bean initialization that depends on the entire singleton graph being ready.
If the initialization must happen after the web server is ready to accept traffic (for example, registering the service with a discovery server), use ApplicationRunner or listen for ApplicationReadyEvent. These fire after refresh() returns and the embedded server is bound to its port.
Pick the narrowest hook that satisfies the timing requirement. @PostConstruct for self-contained bean initialization. SmartInitializingSingleton for cross-bean initialization. ApplicationRunner for post-startup tasks. ApplicationReadyEvent for tasks that should only run when the application is fully ready to serve.