@ynode/autoshutdown
v1.10.2
Published
Fastify 5 plugin that shuts down idle workers after a period of inactivity
Maintainers
Readme
@ynode/autoshutdown
Copyright (c) 2026 Michael Welter [email protected]
A Fastify 5.x plugin that automatically shuts down idle cluster workers after a period with zero in-flight requests. This is useful for conserving system resources in environments where workers are scaled dynamically based on load.
The plugin arms an inactivity timer once the server is listening, cancels it while requests are in flight, and re-arms it after the last response. When the timer expires, it runs any registered cleanup hooks and, unless a hook vetoes shutdown by returning false, gracefully closes the Fastify instance and exits the process.
Why?
The primary benefit of this plugin is resource efficiency, especially in modern, scalable deployments.
In environments that use the Node.js cluster module to spawn multiple workers, traffic is not always evenly distributed. Some worker processes may become idle while others are busy. This plugin identifies those idle workers and shuts them down, freeing up memory and CPU cycles without affecting the overall application's availability.
This becomes even more powerful when combined with process managers like systemd and its socket activation feature. The combination creates a highly efficient, on-demand system:
- systemd socket activation: Starts your application only when a request comes in.
- Node.js clustering: Scales your application across multiple CPU cores to handle the load.
@ynode/autoshutdown: Scales down by removing individual idle workers when they are no longer needed.
This allows your application to dynamically scale both up and down, ensuring you only use the resources you absolutely need at any given moment. 🚀
Node.js support
This package requires Node.js 20.19.0 or newer. CI exercises the exact 20.19.0, 22.13.0, and 24.0.0 boundaries. Node.js 20 remains tested only to preserve the current major-version contract even though upstream support has ended; use Node.js 22 or 24 for supported production deployments. A newly released Node.js major is not considered supported until it is added to CI, even when the open engines range admits it.
Installation
npm install @ynode/autoshutdownBasic Usage
Simply register the plugin with your Fastify instance.
import Fastify from "fastify";
import autoShutdown from "@ynode/autoshutdown";
const app = Fastify({
logger: true,
});
// Register the plugin with custom options
await app.register(autoShutdown, {
sleep: 10 * 60, // 10 minutes of inactivity
grace: 5, // 5-second grace period after startup
ignoreUrls: ["/healthz", /\/admin\/.*/], // Strings or RegExp to ignore
});
app.get("/", (req, reply) => {
reply.send({ hello: "world" });
});
app.get("/healthz", (req, reply) => {
reply.send({ status: "ok" });
});
const start = async () => {
try {
await app.listen({ port: 3000 });
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();Options
The plugin accepts the following options:
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| sleep | number | 1800 | The inactivity period in seconds before shutting down. |
| grace | number | 30 | A grace period in seconds after startup before the inactivity timer is armed. |
| ignoreUrls | Array<string \| RegExp> | [] | Route patterns (e.g. /users/:id) or RegExp patterns to ignore for timer logic. Matched against the route pattern; raw URL fallback for 404s. |
| ignore | (request, path) => boolean | null | Optional function matcher for ignore logic. Return true to ignore that request. |
| jitter | number | 5 | Adds a random delay (in seconds) to the sleep timer to avoid herd shutdowns. |
| force | boolean | false | If true, use server.closeAllConnections() when graceful close exceeds closeTimeout. May drop active clients. |
| exitProcess | boolean | true | If false, plugin closes Fastify but does not call process.exit(...). |
| reportLoad | boolean | false | If true, sends IPC heartbeat messages with Event Loop Lag and memory usage. |
| heartbeatInterval | number | 2000 | Interval in milliseconds for heartbeats and memory checks (must be > 0). |
| hookTimeout | number | 5000 | Maximum time in milliseconds to wait for a shutdown hook. With 0, hooks must settle before the next timer turn. |
| closeTimeout | number | 10000 | Maximum time in milliseconds for each graceful or forced Fastify close phase. |
| memoryLimit | number | 0 | Memory limit in Megabytes (RSS). If exceeded, the server shuts down. 0 = disabled. |
| onShutdownStart | (event, app) => void \| Promise<void> | null | Optional lifecycle observer called when shutdown starts. |
| onShutdownComplete | (event, app) => void \| Promise<void> | null | Optional lifecycle observer called with outcome (closed, vetoed, error). |
Timing Units (Reference)
| Option | Unit | Example |
| ------------------- | --------------- | ------------------------ |
| sleep | seconds | 600 = 10 minutes |
| grace | seconds | 30 = 30 seconds |
| jitter | seconds | 5 = up to 5s of jitter |
| heartbeatInterval | milliseconds | 2000 = 2 seconds |
| hookTimeout | milliseconds | 5000 = 5 seconds |
| closeTimeout | milliseconds | 10000 = 10 seconds |
| memoryLimit | megabytes (RSS) | 512 = 512 MB RSS |
await app.register(autoShutdown, {
sleep: 15 * 60, // seconds
grace: 30, // seconds
jitter: 5, // seconds
heartbeatInterval: 2000, // ms
hookTimeout: 5000, // ms
closeTimeout: 10000, // ms
memoryLimit: 512, // MB (RSS)
});Behavior Matrix
| Situation | inFlight | Timer Action | Shutdown Result |
| --- | --- | --- | --- |
| Startup grace period | 0 | Timer waits until grace ends | No shutdown during grace |
| Non-ignored request starts | +1 | Timer is cancelled | Shutdown paused while request runs |
| Last non-ignored response completes | back to 0 | Timer is re-armed after startup grace | Shutdown may occur after sleep (+ jitter) |
| Non-ignored request aborts or times out | back toward 0 once | Timer is re-armed at 0 after startup grace | Abandoned requests cannot pin a worker active |
| Ignored request (string/RegExp match) | unchanged | Timer is unchanged | Request does not delay shutdown |
| Hook returns false | unchanged | Timer is re-armed | Shutdown is vetoed for that cycle |
| Hook throws or times out | unchanged | Continue current shutdown | Shutdown proceeds |
| RSS exceeds memoryLimit | unchanged | Immediate shutdown sequence | Worker exits after close sequence |
Production Caveats
- The plugin calls
process.exit(0)after successful shutdown andprocess.exit(1)iffastify.close()fails. - Set
exitProcess: falsewhen this plugin runs in-process with other workloads and you do not want worker exit behavior. - Duplicate plugin registration in the same Fastify encapsulation scope throws an error.
ignoreUrlsentries are matched against the Fastify route pattern (for example/users/:id, viarequest.routeOptions.url), not the raw request URL; the raw URL (query string stripped) is used as a fallback only for requests that match no route (404s). String entries must equal the route pattern exactly; useRegExpfor pattern-based matching.- The plugin installs no
SIGTERM/SIGINThandlers; signal-driven shutdown remains the host application's responsibility (process managers or plugins such as@ynode/bootifyand@ynode/clusterown that concern). - Use
ignore(request, path)for method/header/query-aware matching. - Graceful close is bounded by
closeTimeout. Withforce: false, a timeout is reported as a shutdown error. Withforce: true, active connections are closed and the plugin waits one additional bounded close phase. - A reported close timeout does not abort the underlying
fastify.close(). WithexitProcess: false, the instance may still finish closing (or fail) after the"error"outcome was delivered; the plugin logs that late settlement when it happens. heartbeatIntervaldrives both heartbeat emission and memory-limit checks, so very low values can add overhead.- If worker IPC disconnects, load reporting stops but an enabled
memoryLimitcheck continues locally.
Working with @ynode/cluster and @ynode/bootify
A successful idle shutdown calls cluster.worker.disconnect() before process exit. Node marks that exit as voluntary, allowing @ynode/cluster smart pools to reduce desired capacity while still enforcing minWorkers. Failed shutdowns exit non-zero without a voluntary disconnect so Cluster restores capacity.
When Bootify manages the worker, configure only idle behavior through config.sleep. Bootify intentionally reserves load reporting, memory retirement, and process exit ownership for @ynode/cluster; this prevents duplicate heartbeat loops and conflicting replacement decisions.
Advanced Usage
Vetoing a Shutdown with onAutoShutdown
You can register asynchronous hooks that run before a shutdown. If any of these hooks return false, the shutdown is cancelled, and the timer is rescheduled. This is useful for preventing shutdown while critical background tasks are running.
let isTaskRunning = false;
// Register a hook to check the task status
app.onAutoShutdown(async (instance) => {
if (isTaskRunning) {
instance.log.warn("A critical task is running. Cancelling auto-shutdown!");
return false; // This will cancel the shutdown
}
instance.log.info("No critical tasks running. Proceeding with cleanup...");
});
// Example routes to control the simulated task
app.get("/start-task", (request, reply) => {
isTaskRunning = true;
reply.send({ message: "Critical task started. Auto-shutdown will be blocked." });
});
app.get("/stop-task", (request, reply) => {
isTaskRunning = false;
reply.send({ message: "Critical task stopped. Auto-shutdown is now allowed." });
});Lifecycle Hooks (Metrics / Observability)
You can observe shutdown lifecycles either through registration options or decorators:
onShutdownStart(event, app)onShutdownCommit(event, app)onShutdownComplete(event, app)app.onAutoShutdownStart(fn)app.onAutoShutdownCommit(fn)app.onAutoShutdownComplete(fn)
event includes fields such as:
trigger: shutdown source string. Built-in values are"idle_timer"and"memory_limit"; orchestration integrations may pass additional trigger names.startedAt,committedAt,completedAt,durationMsoutcome:"closed","vetoed", or"error"(complete hook)pid,inFlight,nextAt
await app.register(autoShutdown, {
sleep: 10 * 60,
exitProcess: false,
onShutdownStart: (event) => {
app.log.info({ event }, "shutdown started");
},
onShutdownCommit: (event) => {
// Every veto hook accepted. Destructive cleanup is now safe.
app.log.info({ event }, "shutdown committed");
},
onShutdownComplete: (event) => {
app.log.info({ event }, "shutdown finished");
},
});
app.onAutoShutdownComplete((event) => {
if (event.outcome === "error") {
app.log.error({ event }, "shutdown failed");
}
});Custom Ignore Matcher
When URL/RegExp matching is not enough, use ignore(request, path) to define dynamic logic:
await app.register(autoShutdown, {
sleep: 10 * 60,
ignore: (request, path) => {
// Ignore GET health checks and metrics probes
return request.method === "GET" && (path === "/healthz" || path.startsWith("/metrics"));
},
});Decorated Control Surface
The plugin decorates the Fastify instance with a control object, fastify.autoshutdown, for manual control and inspection.
app.autoshutdown.reset(): Manually arms/re-arms the idle timer. It is a no-op before the server starts listening, while startup grace is active, whileinFlight > 0, or after closing begins.app.autoshutdown.cancel(): Manually cancels the timer.app.autoshutdown.shutdown(trigger): Initiates the shutdown sequence with a custom trigger name (default"manual"); the name flows into lifecycle hook events. Throws aTypeErrorunlesstriggeris a non-empty string and resolves once the shutdown attempt settles. Concurrent calls join the active attempt.app.autoshutdown.acquire(label?): Acquires a non-request activity lease and cancels the idle timer. It returns an idempotent release function; the timer is re-armed after the final active lease releases. Optional labels must be non-empty strings and are included in debug diagnostics when an idle-shutdown race stands down.app.autoshutdown.track(promise, label?): Holds an activity lease until a promise-like value fulfills or rejects, returning a promise with the same settlement. Invalid promise-like values or labels throwTypeError.app.autoshutdown.inFlight: (getter) Returns the number of active, non-ignored requests.app.autoshutdown.nextAt: (getter) Returns the epoch timestamp (ms) when the timer will fire, ornull.app.autoshutdown.delay: (getter) Returns the configured base delay in milliseconds.
// Example: Manually reset the timer after a WebSocket message
webSocket.on("message", (data) => {
// some logic...
app.autoshutdown.reset();
});
// Example: orchestrator-driven drain with a custom trigger name
await app.autoshutdown.shutdown("drain");Background Activity Leases
Request tracking cannot see queue consumers, scheduled work, or activity on an ignored long-lived connection. Use a lease to prevent idle shutdown while that work is active:
const release = app.autoshutdown.acquire("invoice-batch");
try {
await processInvoiceBatch();
} finally {
release();
}
// `track` is the concise equivalent and always releases on fulfillment or rejection.
await app.autoshutdown.track(refreshSearchIndex(), "search-index-refresh");Overlapping leases compose: releasing one lease does not re-arm the timer until every lease has been released. A release function may safely be called more than once. Acquiring after shutdown begins throws because the committed shutdown can no longer be held open.
Resource-Based Shutdown
You can configure the plugin to automatically shut down the worker if it consumes too much memory (RSS). This is useful for "self-healing" long-running workers that might have memory leaks.
await app.register(autoShutdown, {
// ... other options
memoryLimit: 512, // Shutdown if RSS > 512 MB
});Note: This check runs on the same interval as
heartbeatInterval(default 2000ms), even ifreportLoadis false.
Load Reporting
When reportLoad: true is set, the plugin sends regular heartbeat messages to the parent process via IPC (if process.send is available). This is useful for external monitoring or load balancing.
Message Format:
{
cmd: "heartbeat",
lag: 12, // Event Loop Lag in ms
memory: { // process.memoryUsage()
rss: ...,
heapTotal: ...,
heapUsed: ...,
external: ...,
arrayBuffers: ...
}
}This allows a process manager (like a custom cluster manager) to track the health and load of each worker.
Parent Process Example:
import cluster from "node:cluster";
// In your primary process code:
cluster.on("message", (worker, message) => {
if (message.cmd === "heartbeat") {
console.log(`Worker ${worker.process.pid} lag: ${message.lag}ms`);
}
});License
This project is licensed under the MIT License.
