Graceful shutdown
Finish in-flight work before the process exits
When a service is stopped, for example during a deploy or when encore run restarts it, it receives SIGTERM (or SIGINT, and Ctrl+C or Ctrl+Break on Windows). Encore.rs handles the signal for you: it stops taking new Pub/Sub messages, waits for in-flight message handlers, closes connections, flushes metrics and traces, and exits the process.
Your HTTP server is part of your app, not Encore, so Encore can't drain its requests on its own. The encore::shutdown module lets the two cooperate: your server stops accepting new requests and finishes the ones in flight, and Encore cleans up after that.
Shutting down an axum server
Pass encore::shutdown::signal() to your server's graceful shutdown, then await encore::shutdown::drained() and exit with the code it returns:
src/main.rs#[tokio::main]
async fn main() {
encore::initialize();
let app = axum::Router::new().route("/", axum::routing::get(|| async { "Hello!" }));
let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or_else(|_| {
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
format!("0.0.0.0:{port}")
});
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(encore::shutdown::signal())
.await
.expect("serve");
std::process::exit(encore::shutdown::drained().await.code());
}
The sequence on a shutdown signal is:
signal()resolves, and axum stops accepting connections and waits for in-flight requests to finish.- At the same time, Encore stops Pub/Sub subscriptions from fetching new messages and waits for the handlers already running.
- When
servereturns,drained()tells Encore your app has finished its work. - Encore closes its Postgres, Redis, and object storage connections, exports a final round of metrics, and sends any buffered trace spans.
drained()returns aShutdownResult, and your app exits with itscode().
Database and other infrastructure connections stay open until your app has drained, so requests that finish during shutdown can still use them.
signal()
pub fn signal() -> impl Future<Output = ()> + Send + 'static
Returns a future that resolves once a shutdown signal is received. Calling it is what makes shutdown cooperative: from then on, Encore's cleanup waits for drained() (or the deadline) instead of starting immediately. Call it at startup, before the signal can arrive, as passing it to with_graceful_shutdown does.
The future can be awaited anywhere, for example to stop a background loop:
let stop = encore::shutdown::signal();
tokio::select! {
_ = stop => {}
_ = run_worker_loop() => {}
}
drained()
pub async fn drained() -> ShutdownResult
Tells Encore your app has finished its work, then waits for Encore's cleanup to complete. If no signal has started a shutdown yet, drained() starts one, so you can also call it when your app decides to exit on its own.
It returns encore::shutdown::ShutdownResult:
Exit with result.code() once drained() returns. Encore leaves exiting to your app in this case, so that code after drained() still runs.
The deadline
Shutdown has a deadline: five seconds unless the environment configures another. If your app hasn't drained by then, Encore proceeds with cleanup anyway and the result is Timeout. Buffered traces and metrics are still flushed after a timeout, for up to five more seconds.
If your app called signal() but never calls drained(), Encore exits the process with code 1 once cleanup completes after the deadline. As a last resort, a process that is still running well after the deadline is forced to exit with code 1.
Apps that don't use signal()
If you never call signal(), shutdown isn't cooperative. On a signal Encore drains Pub/Sub, closes connections, flushes telemetry, and exits the process with the result's code right away, without waiting for your HTTP server. In-flight requests are cut off, and requests still running may find their database connections closed. Use signal() and drained() in any service that serves requests.
Services that serve no requests
A service that only runs Pub/Sub subscriptions or other background work can call encore::run_blocking() from a synchronous main. It starts the registered subscriptions, blocks until a shutdown signal, drains, and exits the process:
fn main() {
encore::initialize();
encore::run_blocking();
}
run_blocking() blocks the calling thread on Encore's own runtime, so call it from a plain fn main, not from inside an async context such as #[tokio::main]. In an async main, await signal() and then drained() instead:
#[tokio::main]
async fn main() {
encore::initialize();
encore::shutdown::signal().await;
std::process::exit(encore::shutdown::drained().await.code());
}
Tests
Encore doesn't install its signal handling under encore test. A test that awaits signal() waits forever.