Skip to content

Lifecycle

Why a Flare app moves through starting, ready, draining, and stopped, and how readiness probes and graceful shutdown work.

AI generated, pending review Updated 10 days ago · Flare 0.3

A Flare app is not just “running” or “not running”. It moves through four states, and host.state tells you which one it’s in: "starting", "ready", "draining", "stopped". This page is about why those states exist and why shutdown drains in-flight work instead of cutting it off. For the exact transition table per runtime and the run() options, see Host.

The host is the composition root: you register config, services, and transport surfaces on it, then call build(). Once it’s compiled, that app has a life of its own. It binds a port, accepts connections, and eventually has to stop. A boolean “up or down” flag can’t describe that honestly, because the moments in between matter.

Two of them matter most. Startup is not instant: an app can be constructed and compiled but not yet listening, and a request that arrives in that window has nowhere to go. Shutdown is not instant either: when a process gets SIGTERM, it usually has requests in flight, and how it treats them is the difference between a clean deploy and a handful of users getting torn connections. The four states name those moments so your code and your infrastructure can react to them.

import { FlareHost } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
const host = new FlareHost(node);
host.state; // "starting" | "ready" | "draining" | "stopped"

host.state is read-only. You never set it. The compiled app advances it as it crosses each transition, and you read it.

When you call host.build(), the graph is validated and compiled, but nothing is listening yet. The app is "starting" from construction until it’s actually serving traffic.

On Node, app.run() does the work that ends the starting phase. It runs the startup hooks, binds the server to its port, and only when the socket is listening does the state become "ready". The order is deliberate: a singleton service’s onStart() runs before the port opens, so a service that needs to warm a cache or open a pool finishes that work before the first request can arrive.

Do this by hand and the ordering is yours to keep: warm the pool first, bind the socket second, and never let the two swap. Getting it backward fails quietly, because it only bites under load. The socket opens a step early, the first burst of a deploy lands on a half-warm pool, and the errors clear the moment warm-up catches up, which is exactly when you stop looking. Flare fixes the order so the readiness flip carries the guarantee: "ready" means the startup hooks already ran.

import { FlareHost, FlareService } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
class CacheWarmer extends FlareService {
static deps = [] as const;
async onStart(): Promise<void> {
// runs during startup, before the server begins listening
}
async onStop(): Promise<void> {
// runs during graceful shutdown, before the app reaches "stopped"
}
}
const host = new FlareHost(node);
host.singleton(CacheWarmer);
const app = host.build();
app.run();

onStart() and onStop() fire for singleton services, not scoped ones. A scoped service lives for one request, so it has no startup or shutdown moment to hook; its cleanup is dispose(), which runs at the end of each request. See Host for where each hook sits.

That "starting" versus "ready" split is what readiness probes are for. A load balancer should not send traffic to an app that’s constructed but not listening. Register a route that reads host.state and returns 200 when it’s "ready", 503 otherwise.

Mark the route { isolated: true } so the probe skips your app’s middleware. A health check should depend on as little as possible: if the probe ran through auth, logging, and rate-limit middleware, a bug in any of those could fail the probe even when the app itself is fine.

import { FlareHost, FlareResponse } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
const host = new FlareHost(node);
host.http.get("/ready", { isolated: true }, () => {
return host.state === "ready"
? new FlareResponse(200, { state: host.state })
: new FlareResponse(503, { state: host.state });
});
const app = host.build();
app.run();

Liveness asks “is the process up at all?” Any route that responds is enough. Readiness asks “should this instance get traffic right now?” That’s the host.state question. An app can be alive but "starting" or "draining", and in both cases it should not receive new work.

Don’t gate the probe on a database ping unless you want the load balancer to drop this instance when that dependency is down. A readiness probe that fails on every transient blip causes more outages than it prevents.

The interesting state is "draining". It exists to answer one question: when the process is told to stop, what happens to the requests that are already running?

The blunt answer is to close the server immediately and let those connections drop. That’s fast, and it’s wrong. A request that was three lines from returning a 200 instead returns nothing, and the user sees a reset connection. On a rolling deploy, that’s a small fraction of every request in flight at swap time, on every instance, every release.

Flare drains instead. When handle.stop() is called, or the process receives SIGTERM or SIGINT, the app sets host.state to "draining" and splits inbound traffic into two groups. Requests already inside the pipeline keep running until they finish. New requests are turned away at the door, before the pipeline runs, with a fixed response:

  • Status 503
  • Body {"error":"Service Unavailable"}
  • Header Connection: close

The Connection: close header is the load balancer’s signal to stop reusing that socket. The 503 says “not me, route elsewhere”. Meanwhile the in-flight requests get to complete, so nobody mid-request loses their answer. This is host lifecycle, not pipeline dispatch: the drain 503 is produced before any route handler or error mapper runs, so it never reaches host.http.error.

If your load balancer watches /ready and you flip it before drain begins, traffic stops arriving on its own and the window where anything sees a 503 shrinks to almost nothing.

Drain has a deadline. The app waits for in-flight requests to finish, but only up to the shutdown timeout (shutdownTimeout, default 10000 ms). If requests are still running when that elapses, the app stops waiting and force-closes the remaining connections rather than hanging forever. The timeout is the upper bound on how long a graceful stop can take.

Once the active requests have drained (or the timeout fired), the app closes the server, runs the onStop() hooks on singleton services, and sets host.state to "stopped". By the time you observe "stopped", the server is closed and teardown has run.

import type { NodeRunHandle } from "@flare-ts/core/node";
import { FlareHost } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
const host = new FlareHost(node);
const app = host.build();
const handle: NodeRunHandle = app.run({ shutdownTimeout: 10_000 });
// later, on a deploy signal:
await handle.stop(); // resolves once the app reaches "stopped"

The four states describe a long-lived server, which is the Node story. On Cloudflare Workers there’s no process you drain: app.export() runs startup hooks, sets host.state to "ready", and returns the fetch handler. Each request is handled by the platform’s own per-invocation model, so "draining" and "stopped" are Node lifecycle, not the Workers one. Test mode runs its own startup through app.test() and reuses the same state field so probes behave the same way under test.

The lesson to carry: host.state is the app telling you where it is in its own life, and draining is Flare choosing to finish in-flight work over stopping the instant it’s asked.

  • Host: the lifecycle transition table per runtime, run() / export() / test(), and the NodeRunHandle surface.
  • What build() does: the step that ends construction and produces the app whose lifecycle this page describes.
  • HTTP error handling: what bypasses route handlers, including the drain 503.