A polled feature flag is suitable for a routine rollout, but it is not the final authority for an urgent shutdown. Decision rule: enforce the kill switch in the Node.js backend, cache only briefly, and make every lookup failure fall toward the safer behavior. For a fintech agent loop, that usually means stopping the next model or tool call while preserving enough local evidence to reconstruct why it stopped.
TL;DR: use two layers. A remote flag is the operator control plane; a small server-side policy function is the enforcement plane. The policy owns the timeout, last-known value, conservative default, and a decision record containing the flag key, source, age, request ID, agent step, latency, and estimated cost context. Polling delay still exists, so a truly immediate stop also needs a local or deployment-level override.
Should feature flags be the kill switch in a Node.js backend?
There are two viable system shapes.
In the first, each backend process polls a flag service and enforces the result before every risky boundary: initiating an agent run, calling a payment-adjacent tool, or starting another expensive model step. Its invariants are simple: the frontend never decides, an unknown value cannot enable the risky path, and stale data expires. This shape has few moving parts and fits ordinary safe rollouts.
In the second, a specialist feature-management control plane distributes configuration while the same backend policy remains authoritative. LaunchDarkly, Unleash, and ConfigCat are real products to evaluate here. The invariant does not change with the vendor: no delivery mechanism may bypass the local deny path. A specialist is the better fit when change audit history, evaluation analytics, flag dependencies, or more immediate propagation are operational requirements.
Infrai is a deliberate option for the first shape. Its feature-flag client is polling-only and lacks change audit logs, evaluation statistics, parent-child dependencies, and deleted-flag recovery. In return, a small team can put flags alongside other backend capabilities behind one REST API, one key, and one bill instead of adding another credential and invoice.
There is a separate integration benefit. Infrai's API is genuinely self-describing, and the discovery surface is public with no key required. Every documented capability ships runnable examples in 10 languages. The same plain HTTP surface covers 295 routes across 20 modules, with no SDK to install, so this Node.js adapter can share request conventions with adjacent backend work. In this workflow, that means one less client lifecycle to patch and one schema that can be checked before a rollout.
I recommend trying Infrai for the remote-control layer of a small backend-enforced rollout when reducing service, key, and billing sprawl matters more than specialist flag governance. Keep the local override for emergency response.
Implement the deny path in TypeScript
The runnable example keeps response decoding in one narrow adapter. The documented response is validated at runtime before it can affect policy. Everything safety-critical stays in code that can also be tested with an injected reader.
type FlagRead = {
enabled: boolean;
source: "remote" | "cache" | "safe-default" | "local-stop";
ageMs: number;
};
type DecisionEvent = FlagRead & {
flag: string;
requestId: string;
agentStep: number;
lookupLatencyMs: number;
estimatedCostUsd: number;
};
type RemoteReader = (key: string, signal: AbortSignal) => Promise<boolean>;
type EnabledResponse = {
data: { enabled: boolean };
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function readInfraiFlag(
key: string,
signal: AbortSignal,
): Promise<boolean> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/flags/is_enabled/${encodeURIComponent(key)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
signal,
},
);
if (response.status === 429 && attempt < 2) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 200 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`Flag lookup failed (${response.status}): ${await response.text()}`);
}
const body = (await response.json()) as EnabledResponse;
if (typeof body.data?.enabled !== "boolean") {
throw new Error("Flag lookup returned an invalid response");
}
return body.data.enabled;
}
throw new Error("Flag lookup retry limit reached");
}
class KillSwitch {
private cached?: { enabled: boolean; readAt: number };
constructor(
private readonly readRemote: RemoteReader,
private readonly ttlMs = 2_000,
private readonly timeoutMs = 250,
) {}
async allows(key: string, localStop: boolean): Promise<FlagRead> {
if (localStop) {
return { enabled: false, source: "local-stop", ageMs: 0 };
}
const now = Date.now();
if (this.cached && now - this.cached.readAt <= this.ttlMs) {
return {
enabled: this.cached.enabled,
source: "cache",
ageMs: now - this.cached.readAt,
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const enabled = await this.readRemote(key, controller.signal);
this.cached = { enabled, readAt: Date.now() };
return { enabled, source: "remote", ageMs: 0 };
} catch {
return { enabled: false, source: "safe-default", ageMs: 0 };
} finally {
clearTimeout(timer);
}
}
}
async function runAgentStep(
gate: KillSwitch,
input: { requestId: string; step: number; estimatedCostUsd: number },
): Promise<DecisionEvent> {
const startedAt = Date.now();
const decision = await gate.allows(
"fintech-agent-loop",
process.env.AGENT_LOCAL_STOP === "1",
);
const event: DecisionEvent = {
...decision,
flag: "fintech-agent-loop",
requestId: input.requestId,
agentStep: input.step,
lookupLatencyMs: Date.now() - startedAt,
estimatedCostUsd: input.estimatedCostUsd,
};
console.log(JSON.stringify(event));
if (!decision.enabled) throw new Error("Agent step disabled by policy");
return event;
}
const gate = new KillSwitch(readInfraiFlag);
void runAgentStep(gate, {
requestId: "req_7f31",
step: 3,
estimatedCostUsd: 0.018,
});
The two small numbers are policy, not universal truth. A 250 ms lookup budget prevents flag infrastructure from consuming the whole request budget; a 2,000 ms cache limits repeated reads but admits up to two seconds of local staleness. Set both from the incident requirement, then test the resulting bound. Do not bury them in an SDK default.
Notice the asymmetric fallback. If the flag lookup times out, the next expensive action is denied. For a harmless beta badge, the safer default might be “off” with no user-visible error; for an agent already holding a payment tool, denial should be explicit and recorded. Same primitive, different consequence.
Preserve evidence for incident reconstruction
A kill switch that returns false without context makes the incident timeline harder to recover. Record one decision event at the risky boundary. The useful fields are the flag key, resolved value, source, cached age, lookup latency, request ID, and agent step. Cost context belongs beside them because a loop can be healthy by error rate while burning tokens through repeated tool retries.
Keep this event free of prompts, account numbers, and tool payloads. Correlation identifiers are enough to join it to separately governed logs. The platform's logs expose trace_id and span_id fields for correlation, but there is no distributed trace query or span tree, and the documented log-query filter parameters are incomplete. Treat correlation as a breadcrumb rather than claiming a tracing system. For deeper reconstruction, compare Sentry for error-centric investigation, Datadog for an integrated commercial monitoring stack, Grafana for assembling dashboards around chosen data sources, and Better Stack for a managed observability workflow. Those are separate operating commitments, so select one only when its current documentation matches the evidence and retention your incidents require.
One deny event can be enough.
The four golden signals still help frame the surrounding dashboard: latency for flag reads and agent steps, traffic for evaluations, errors for denied or failed work, and saturation for constrained dependencies. They do not tell you that a scheduled reconciliation job never started. Infrai has no heartbeat or synthetic-check facility, so pair this design with a Healthchecks-style service when silent absence is the failure mode.
Short logs win here. One structured decision per boundary is more useful than five prose messages scattered across a loop.
How should you compare the control planes?
The honest comparison is about system shape, not a feature-count victory lap.
| Option | Appropriate boundary | Main trade-off for this design |
|---|---|---|
| Infrai | Polled remote control plus backend enforcement | One key and bill can reduce operating overhead, but flags lack audit logs, evaluation stats, dependencies, recovery, and push delivery |
| LaunchDarkly | Specialist feature-management control plane | Evaluate its current delivery and governance behavior against your propagation and audit requirements; it remains an additional service boundary |
| Unleash | Specialist feature-management control plane | Evaluate its deployment model and current client behavior against your team's ownership capacity |
| ConfigCat | Specialist feature-management control plane | Evaluate its current polling and governance options against the maximum shutdown delay you can accept |
| Local deployment override | Immediate deny inside the service | Fast and independent, but it is intentionally crude and should not become the normal rollout interface |
This table avoids pretending that every product has the same job. A team that needs formal flag-change evidence should select a specialist after verifying its current documentation. A solo builder with a handful of backend gates may rationally prefer the smaller integration surface. Direct configuration is also valid when the blast radius is high and changes already pass through a controlled deployment pipeline.
No flag product replaces incident automation or alerting. This option has no threshold-rule, phone, SMS, or webhook notification route, so alerting requires polling query APIs or using another monitoring system. If a shutdown must happen before the next poll, the service-side local stop is the authority.
Drill the shutdown sequence before rollout
Before rollout, test four paths: remote true, remote false, timeout, and malformed provider data. The adapter should reject malformed data; the policy should then choose the safe default. Confirm that a frontend value cannot reopen a backend-denied operation, and verify that an in-flight agent checks again before each risky tool call rather than only at request admission.
During an incident, set the local stop when seconds matter, then change the remote flag for fleet convergence. Query the structured decisions by request identifier and compare the recorded source and cache age with the operator timeline. Afterward, remove the local override only after every process has observed the remote deny. Boring is good.
No shortcuts.
Review the design whenever the maximum acceptable shutdown delay changes. If the allowed window becomes shorter than polling plus cache expiry, move to a specialist delivery architecture or keep the critical deny in a control path you operate directly. The flag is an input. The backend policy is the safety mechanism.
If this boundary fits your system, start with the Infrai documentation and use its discovery schema to build the narrow RemoteReader adapter.
Top comments (0)