For a small Node.js SaaS, use health monitoring to detect customer impact and a separately controlled feature flag kill switch to disable a broken rollout fast.
Don't make the health check flip the switch by itself. Let monitoring answer, "Are users hurting?" Let the flag answer, "Can I route around this feature without a rollout?" The on-call runbook connects those two jobs.
My default shape is plain: a server-side flag, a known fallback, shallow liveness, meaningful readiness, an outside-in check, and error and latency measurements split by the selected path. During an outage, I pause the rollout, compare both paths, disable the suspect feature, and watch the same user-facing signals for recovery.
Fast. Reversible. Visible.
How should a small Node.js SaaS pair a feature flag kill switch with health monitoring?
Start with the failure you need to control. This table is the field guide; the rest of the article explains how to wire the first row without turning monitoring into a dangerous automatic trigger.
| Control | Pick this when | Test before relying on it | Choose something else when |
|---|---|---|---|
| Server-side feature flag | One request path can safely fall back to another | Instances use a documented safe value when state is stale | The change isn't a reversible path choice |
| Environment setting | The team can wait for a configuration rollout | Every instance converges on the intended value | Operators need a sub-deploy response time |
| Deployment rollback | An earlier application version remains compatible | Old code can safely read current data and configuration | The release included an irreversible data change |
| Circuit breaker | Repeated dependency calls should stop automatically | Opening, cooldown, and recovery probes behave as intended | A human must make a product decision |
Think in two loops. The detection loop starts outside the process: an uptime check reaches the public route, application measurements describe errors and duration, and an alert reports sustained customer impact. The control loop starts with an authenticated operator: the operator changes one bounded flag, running instances observe the value, and later requests take the tested fallback. They meet in a runbook, not in a callback that turns one failed probe into a global shutdown.
Here is my diagram in words. Before: alert -> inspect release -> revert commit -> build -> deploy -> wait for readiness -> verify. After: alert -> compare flagged paths -> disable feature -> verify. A flag shortens the control path. It doesn't diagnose the incident, repair data, or make a weak fallback safe.
Keep liveness narrow. It should establish that the Node.js process can respond, so a temporary dependency failure doesn't cause a restart loop. Readiness can be stricter because it answers a different question: should this instance receive traffic now? An outside-in check still matters; an internal endpoint may be green while DNS, TLS, routing, or the actual customer path is unavailable.
One green probe isn't observability.
For the alert, prefer a sustained error ratio or latency threshold on the affected route over a single probe failure. The runbook should name the flag, owner, fallback, dashboard view, and verification query. I also separate temporary rollout flags from permissions and permanent configuration. During a rough incident, invoice-preview-v2 must have one obvious meaning.
The catch is that a kill switch only helps when the old path still works and is safe to call. It's not suitable for reversing a destructive schema change, restoring a dependency, undoing corrupted writes, or draining a queue. Use migration controls, backups, timeouts, rate limits, and deployment rollback for those jobs.
Pick the control that matches the failure
A remote server-side flag earns its operational weight when it gives an on-call engineer narrow access, an audit trail, predictable evaluation, and an explicit stale-state policy. It is the strongest fit for a risky request path that has a tested alternative. The switch changes routing; it does not repair data or heal dependencies.
Stick with deployment configuration when the behavior can wait for a rollout and targeted exposure isn't required. Pick deployment rollback when shared initialization, security policy, or code outside the flagged boundary changed. A boolean is too weak for an irreversible operation; that needs confirmation, authorization, and a durable audit event.
A circuit breaker solves a narrower problem. It can stop repeated calls to a failing dependency and probe for recovery, but it should not silently make a product-level choice that changes customer behavior. The distinction is useful during an incident: breakers protect a dependency boundary, flags select an application path, and probes report state.
Monitoring has its own layers. An outside-in check catches failures beyond the process. Application measurements explain which selected path is slow or failing. Structured logs carry incident detail. The Twelve-Factor App treats logs as event streams: the application writes each event to standard output, while the execution environment handles collection and routing. That keeps file rotation and destination details out of feature code.
Telemetry volume is an operational constraint, too. Set retention, sampling, and volume alerts before rollout because hosted log systems may charge for ingestion; current pricing and tiers can change, so verify them at the source. Don't wait until the incident to decide which debug fields are worth keeping.
Build one boring, copyable control path
I teach the interface before the product. The example below assumes a flag provider supplies getBoolean; it doesn't prescribe how that provider stores, polls, or audits values. The request handler evaluates the flag once, chooses an implementation, and emits a bounded path label. That boundary keeps flag logic from leaking through every function.
import { createServer, IncomingMessage, ServerResponse } from "node:http";
type FlagClient = {
getBoolean(name: string, fallback: boolean): boolean;
};
type Metrics = {
observe(name: string, valueMs: number, labels: Record<string, string>): void;
};
declare const flags: FlagClient;
declare const metrics: Metrics;
declare function runExistingPath(request: IncomingMessage): Promise<unknown>;
declare function runNewPath(request: IncomingMessage): Promise<unknown>;
let ready = false;
async function handleFeature(
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
const startedAt = performance.now();
const useNewPath = flags.getBoolean("invoice-preview-v2", false);
const path = useNewPath ? "new" : "existing";
try {
const result = useNewPath
? await runNewPath(request)
: await runExistingPath(request);
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(result));
metrics.observe("feature_request_duration_ms", performance.now() - startedAt, {
path,
outcome: "success",
});
} catch (error: unknown) {
metrics.observe("feature_request_duration_ms", performance.now() - startedAt, {
path,
outcome: "error",
});
response.writeHead(502, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "dependency_unavailable" }));
}
}
createServer(async (request, response) => {
if (request.url === "/health/live") {
response.writeHead(200).end("alive");
return;
}
if (request.url === "/health/ready") {
response.writeHead(ready ? 200 : 503).end(ready ? "ready" : "starting");
return;
}
if (request.url === "/invoice/preview" && request.method === "POST") {
await handleFeature(request, response);
return;
}
response.writeHead(404).end("not found");
}).listen(3000, () => {
ready = true;
});
Keep it boring.
In production, both implementations need the same input, output, authorization, and idempotency contract. Exercise the fallback continuously; code that hasn't handled real traffic in months isn't an emergency path I trust. Keep measurement labels bounded to values such as new, existing, success, and error. Don't attach account IDs, request IDs, or raw URLs to metric labels.
Startup behavior needs a deliberate test too. For an optional risky feature, I default the flag to false until current state is available. Your mileage may vary when the new path protects a required migration, which is precisely why the fallback belongs in a written decision and a restart test.
Run the outage drill before the outage
The first response should be mechanical: acknowledge the alert, freeze rollout changes, compare the new and existing paths, disable the flag if the new path correlates with impact, then verify outside-in availability, error ratio, and latency. Preserve the timeline for diagnosis. Don't require a committee for the emergency action; grant a narrow on-call role and retain an audit event.
I won't connect one failed health probe directly to a global flag. A probe can fail because of one instance, one network path, one dependency, or the checker itself. Automatic disabling needs a stable signal, a minimum duration, a bounded scope, a cooldown, and a re-enable policy. A small SaaS usually gets a safer first version from a clear alert plus one accountable human decision — especially before the team has run controlled failure drills.
Measure the transition. Emit a structured event when an operator changes the control, then chart when running instances begin selecting each path. The incident timeline should answer four questions: when did customer impact begin, when did the flag change, when did application behavior change, and when did customer signals recover? Logs carry detail; bounded metrics make the comparison fast.
Then resist the quick re-enable. Diagnose the broken path, add a regression test, deploy the repair behind the disabled flag, and expose it to a small low-risk cohort. Expand only while its signals match the fallback. Remove the temporary flag after the agreed observation window, along with the dead branch and its special dashboard split. Otherwise yesterday's safety device becomes tomorrow's unreadable nest of booleans.
Flags do make code harder to read when evaluation is scattered, so evaluate near the request boundary, pass the selected implementation inward, and assign an owner and removal date. A remote flag service can also become a dependency, so test process restart without fresh state and verify the exact caching and fallback behavior of the implementation you choose. I'm not sure a generic stale-state recommendation can be responsible here: the correct default depends on whether the new path is optional or protects a required migration. Resolve that uncertainty with a restart test and a written per-flag decision.
Limits and handoff
This pattern is not suitable when requests have already caused irreversible writes, the fallback cannot read current data, or the failure sits outside the flagged boundary. Stick with rollback for a compatible bad release, use circuit breaking for repeated dependency calls, and use data-recovery controls for corrupted state. The flag is a fast routing control — nothing more.
Keep the final handoff short: preserve the incident timeline, name the repair owner, and set a removal date for the temporary flag. Then leave the switch disabled until a regression test and a limited rollout show that the repaired path matches the fallback's customer-facing signals.
Sources
- The Twelve-Factor App, “Logs”: https://12factor.net/logs
- Amazon CloudWatch pricing, including log ingestion pricing: https://aws.amazon.com/cloudwatch/pricing/
Top comments (0)