Understanding the complete shutdown lifecycle from JVM hooks to bean destruction

Most developers spend time optimizing application startup.
Far fewer think about shutdown.
Yet every production deployment, Kubernetes rolling update, container restart or Ctrl+C follows the same lifecycle. During those few seconds Micronaut stops the HTTP server, publishes shutdown events, invokes lifecycle callbacks, destroys managed beans and finally exits the JVM.
Unlike Spring Boot, Micronaut intentionally exposes shutdown as a collection of independent lifecycle extension points rather than one strictly ordered pipeline. Understanding which callback belongs where — and how a couple of them relate to each other — is the key to writing predictable cleanup logic.
Everything below targets Micronaut 3.0+, since BeanPreDestroyEventListener doesn't exist before that version.
⚡ TL;DR (Quick Recap)
- Micronaut provides several shutdown mechanisms, each intended for a different responsibility.
- Shutdown ordering is only guaranteed where bean dependency relationships require it.
- @PreDestroy remains the preferred choice for releasing resources owned by a bean.
- ServerShutdownEvent is not a sibling of ApplicationShutdownEvent — it's a subclass of it, so a single server shutdown can trigger both listeners from one publish.
- Custom JVM shutdown hooks execute independently of Micronaut and should not interact with application beans.
Why Shutdown Matters
Stopping an application is much more than terminating a JVM.
Before the process exits, the framework must:
- stop accepting HTTP requests
- notify interested components
- stop lifecycle-aware beans
- release resources
- destroy singleton beans
- close the application context
- terminate the JVM
Each of these responsibilities has its own extension point. Choosing the wrong one often results in race conditions, resource leaks or shutdown code that executes too late — or in the case of some callbacks, code that fires more often than you’d expect.
A Single Bean Demonstrating Every Shutdown Hook
Unlike Spring Boot, Micronaut intentionally separates several shutdown extension points across different APIs.
Rather than creating multiple examples, we’ll use one bean implementing every available callback.
@Singleton
class ShutdownEventsBean {
private static final Logger log = LoggerFactory.getLogger(ShutdownEventsBean.class);
@EventListener
void onServerShutdown(ServerShutdownEvent event) {
log.info(
"ServerShutdownEvent - {}:{}",
event.getSource().getHost(),
event.getSource().getPort());
}
// Also fires for the same publish that triggers onServerShutdown() above,
// because ServerShutdownEvent IS an ApplicationShutdownEvent. See the
// "Shutdown Timeline" section below before you rely on these firing separately.
@EventListener
void onAppShutdown(ApplicationShutdownEvent event) {
log.info("ApplicationShutdownEvent");
}
@EventListener
void onShutdown(ShutdownEvent event) {
log.info("ShutdownEvent");
}
}
@Singleton
class PreDestroyBean {
private static final Logger log = LoggerFactory.getLogger(PreDestroyBean.class);
@PreDestroy
void preDestroy() {
log.info("@PreDestroy");
}
}
@Singleton
class LifeCycleBean implements LifeCycle<LifeCycleBean> {
private static final Logger log = LoggerFactory.getLogger(LifeCycleBean.class);
private final AtomicBoolean running = new AtomicBoolean(true);
@Override
public LifeCycleBean start() {
return this;
}
@Override
public LifeCycleBean stop() {
running.set(false);
log.info("LifeCycle.stop()");
return this;
}
@Override
public boolean isRunning() {
return running.get();
}
}
@Singleton
class PreDestroyBeanPreDestroyListener implements BeanPreDestroyEventListener<PreDestroyBean> {
private static final Logger log = LoggerFactory.getLogger(PreDestroyBeanPreDestroyListener.class);
@Override
public PreDestroyBean onPreDestroy(BeanPreDestroyEvent<PreDestroyBean> event) {
log.info("BeanPreDestroyEventListener - PreDestroyBean about to be destroyed");
return event.getBean(); // must not return null - Micronaut wraps a null return in a BeanDestructionException
}
}
class ExternalClass {
private static final Logger log = LoggerFactory.getLogger(ExternalClass.class);
public void shutdown() {
log.info("@Bean(preDestroy) - ExternalClass.shutdown()");
}
}
@Factory
class ExternalClassFactory {
@Bean(preDestroy = "shutdown")
@Singleton
ExternalClass externalClass() {
return new ExternalClass();
}
}
public class ShutdownApplication {
private static final Logger log = LoggerFactory.getLogger(ShutdownApplication.class);
public static void main(String[] args) {
Runtime.getRuntime()
.addShutdownHook(new Thread(
() -> log.info("Custom JVM shutdown hook (non-deterministic vs Micronaut's hook)"),
"custom-shutdown-hook"));
Micronaut.build(args).banner(false).eagerInitSingletons(true).start();
}
}
Notice that LifeCycleBean never registers a @PreDestroy method or an event listener — yet its stop() still runs on shutdown. That's because LifeCycle extends Closeable/AutoCloseable and provides a default close() that delegates to stop().
The Shutdown Timeline — What Actually Fires
Running the sample and pressing Ctrl+C produces something similar to:
[JVM Shutdown Initiated (Ctrl+C / SIGTERM)]
│
├──► (Concurrently) Custom JVM Shutdown Hook Runs
│
▼
[Micronaut Shutdown Hook Begins]
│
├──► 1. ServerShutdownEvent (Server stops accepting traffic)
├──► 2. ApplicationShutdownEvent (Embedded app stops)
├──► 3. ShutdownEvent (BeanContext closing sequence starts)
│
▼
[Bean Destruction Phase]
│
├──► 4. LifeCycle.stop() (For AutoCloseable beans)
├──► 5. BeanPreDestroyEventListener (Before-destruction intercepts)
└──► 6. @PreDestroy / @Bean(preDestroy) (Resource cleanup)
This sequence should still not be read as a strict, guaranteed contract. Micronaut guarantees ordering only where it’s implied by bean dependencies. Independent beans may be destroyed in different relative orders across runs and applications should never depend on destruction timing unless one bean explicitly depends on another.
Understanding Each Shutdown Mechanism
JVM Shutdown Hook — The JVM shutdown hook belongs to Java itself rather than Micronaut. It executes whenever the JVM terminates and is useful for releasing resources that exist outside the Micronaut context.
Avoid accessing Micronaut beans from here — by the time it runs, the framework may already be mid-shutdown or fully closed and there’s no guaranteed ordering between this hook and Micronaut’s own internal shutdown hook.
ServerShutdownEvent — Published when the embedded HTTP server begins stopping. Because it’s a subclass of ApplicationShutdownEvent, any listener registered for the parent type will also see this event.
This is the appropriate place to:
- deregister from service discovery
- notify load balancers
- publish final server metrics
If your application exposes HTTP endpoints, this is usually the earliest framework callback you’ll receive.
ApplicationShutdownEvent — Represents the application shutting down. It’s the direct parent type of ServerShutdownEvent, so listen for this one when you want shutdown logic to run regardless of which embedded runtime (HTTP server, messaging consumer, etc.) is stopping — not when you specifically care about the server.
Remember its scope: it requires an EmbeddedApplication source, so it won't necessarily fire in a bare CLI application.
ShutdownEvent — Published when the underlying BeanContext begins closing. Because every Micronaut application has a BeanContext, this is the one event guaranteed to work for CLI applications or services without an HTTP server — unlike ApplicationShutdownEvent.
LifeCycle.stop() — Beans implementing LifeCycle participate directly in the application lifecycle. In practice, stop() gets called during context shutdown because LifeCycle's default close() delegates to it, and Micronaut invokes close() on AutoCloseable singleton beans as part of destroying them — not through any separate lifecycle registry.
This callback is commonly used by infrastructure components that maintain long-running background work (connection pools, background pollers, schedulers you’ve built yourself).
BeanPreDestroyEventListener<T> — This callback observes another bean immediately before it is destroyed, before that bean’s own @PreDestroy methods run. Rather than cleaning up its own resources, it allows infrastructure code to react to another bean's destruction — useful for framework extensions and cross-cutting concerns (auditing, metrics, cache invalidation tied to a specific bean type).
Two behaviors worth knowing before you rely on it in production:
- It must return a non-null bean from onPreDestroy(). A null return is wrapped in a BeanDestructionException.
- Any exception thrown inside it is also wrapped and propagated as a BeanDestructionException, which can interrupt the destruction of that bean.
This interface requires Micronaut 3.0+.
@PreDestroy — The standard Jakarta lifecycle annotation. This remains the preferred mechanism for releasing resources owned by an individual bean, including:
- HTTP clients
- file handles
- executors
- caches
- background threads
Because it’s framework-independent, it also improves portability between Micronaut, Spring and Jakarta EE. Keep in mind Micronaut doesn’t enforce a timeout on @PreDestroy methods by default — a slow or blocking cleanup method can extend your shutdown window (and, under Kubernetes, risk hitting terminationGracePeriodSeconds before cleanup finishes).
@Bean(preDestroy=”…”) — Sometimes the class requiring cleanup can’t be modified — a third-party SDK client, for instance. Micronaut allows a factory method to specify which method should be invoked on the bean when it’s destroyed.
This is commonly used for third-party SDKs and external libraries that expose their own close()/shutdown().
One Important Difference from Spring Boot
Spring Boot defines a well-orchestrated shutdown pipeline involving graceful HTTP shutdown, SmartLifecycle phases, application events and bean destruction.
Micronaut intentionally keeps shutdown simpler. Micronaut has no direct equivalent to Spring’s phased SmartLifecycle ordering.
As a result, cleanup logic should always be attached to the callback whose responsibility matches the resource being released, rather than relying on execution order between unrelated beans.
Which Shutdown Mechanism Should You Use?
Each callback serves a distinct purpose:
- JVM shutdown hook → cleanup outside Micronaut
- ServerShutdownEvent → server-specific shutdown
- ApplicationShutdownEvent → application-wide shutdown
- ShutdownEvent → context shutdown notifications
- LifeCycle.stop() → lifecycle-managed infrastructure
- BeanPreDestroyEventListener<T> → observe another bean's destruction
- @PreDestroy → preferred cleanup mechanism for most beans
- @Bean(preDestroy="...") → third-party classes you cannot modify
Choosing the right callback keeps shutdown predictable and avoids resource leaks.
Final Takeaways
The best shutdown code isn’t the one that runs first — it’s the one attached to the lifecycle callback that owns the resource you’re cleaning up.
Instead, it provides focused lifecycle events that each solve a specific problem — server shutdown, application shutdown, context shutdown, lifecycle-aware beans and bean destruction.
Knowing this order makes it much easier to decide where cleanup belongs and ensures your applications shut down gracefully in production, whether triggered by Ctrl+C, Kubernetes, Docker or rolling deployments.
You can find an example on GitHub.
Originally posted on marconak-matej.medium.com.