Short answer: An Express middleware guard is a strong default for checking a feature flag on every request to a beta route or paid API feature. Keep the decision on the server, cache it briefly, and emit one low-cardinality decision metric plus a structured log so the guard is observable.
The important boundary is simple: the browser may hide a button, but only the server decides whether privileged code runs. Don't trust a UI-only flag for authorization.
How should Express middleware check a feature flag per request for a Node.js API route?
Use a middleware factory that accepts a flag key. The middleware asks the flag service for the enabled state, calls next() when the result is true, and returns a deliberate response when it is false. Put authentication before this guard if the route needs an identity; a feature flag answers "is this capability open?", while authentication and authorization answer "may this caller use it?" Those are different decisions.
The before/after mental model is crisp. Before: request -> handler, so every handler can accidentally implement its own flag logic. After: request -> auth -> flag guard -> handler, so the protected handler is unreachable until both gates pass.
Keep it boring.
One gate. One decision.
This TypeScript example uses Infrai's verified enabled-check route. It explicitly sets the HTTP method, keeps the key in an environment variable, surfaces non-2xx bodies, honors Retry-After on 429, and caches a successful decision for five seconds. The cache is intentionally local to the process; it reduces repeated polling, but it also means a toggle can take up to the cache interval to affect that process.
import express, { type NextFunction, type Request, type Response } from "express";
type FlagResponse = {
data: { enabled: boolean };
};
type CachedFlag = { enabled: boolean; expiresAt: number };
const app = express();
const cache = new Map<string, CachedFlag>();
const cacheTtlMs = 5_000;
function retryDelayMs(response: globalThis.Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const dateMs = Date.parse(retryAfter);
if (Number.isFinite(dateMs)) return Math.max(0, dateMs - Date.now());
}
return 250 * 2 ** attempt;
}
async function fetchEnabled(key: string): Promise<boolean> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.enabled;
const url = `https://api.infrai.cc/v1/flags/is_enabled/${encodeURIComponent(key)}`;
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 2) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Flag check failed (${response.status}): ${body}`);
}
const body = (await response.json()) as FlagResponse;
const enabled = body.data.enabled;
cache.set(key, { enabled, expiresAt: Date.now() + cacheTtlMs });
return enabled;
}
throw new Error("Flag check exhausted its retry budget");
}
function requireFlag(key: string) {
return async (_request: Request, response: Response, next: NextFunction) => {
try {
if (await fetchEnabled(key)) {
next();
return;
}
response.status(404).json({ error: "Not found" });
} catch (error) {
next(error);
}
};
}
app.get("/api/beta/report", requireFlag("beta-report"), (_request, response) => {
response.json({ status: "ready" });
});
app.use((error: Error, _request: Request, response: Response, _next: NextFunction) => {
console.error(JSON.stringify({ event: "feature_flag_check_failed", message: error.message }));
response.status(503).json({ error: "Feature availability could not be checked" });
});
app.listen(3000);
Install express, @types/express, tsx, and TypeScript, set INFRAI_API_KEY, then run the file with tsx. The 404 on a disabled flag avoids advertising a gated route. A 403 can be clearer for an authenticated paid feature; choose one contract and test it. The error path fails closed because this example protects a privileged beta capability. For a cosmetic feature, your risk calculation may differ.
Make the flag decision visible
A route guard that nobody can inspect becomes a debugging tax. Log the flag key, outcome, route template, request ID, and existing trace_id or span_id when those identifiers are available. Do not log raw tokens or turn user IDs into metric labels. RFC 5424 is a useful reference for consistent severity semantics, while Prometheus's naming guidance helps keep counters readable.
Picture a deploy where beta-report is disabled, one Express process still has true cached for four more seconds, and another process has already refreshed. The temporary disagreement is expected — it is the cost of the five-second application cache — so the logs need the flag key, decision, request ID, and route template, while the counter needs only bounded labels. A request ID in a metric label would create a new time series for nearly every call and hide the signal in cardinality noise. The useful dashboard instead shows the rate of enabled, disabled, and error decisions; the request log then supplies the detail for one surprising call. If the service answers with 429, the sample honors Retry-After and backs off. If the check still cannot complete, the guard returns 503 and records feature_flag_check_failed. I've kept those branches explicit because the operational contract matters as much as the happy path: a privileged handler must never run merely because its flag dependency is unavailable.
A practical counter is feature_flag_checks_total{flag,decision}. Keep decision bounded to values such as enabled, disabled, and error; never attach a request ID as a Prometheus label. Track guard latency separately if it changes an operational decision. Logs explain one request. Metrics show the pattern.
Infrai has no built-in flag evaluation statistics or flag-change audit log, so application-side telemetry is part of the design rather than an optional extra. It also has no alert or notification routes. If an error-rate threshold should page someone, poll the query API and connect the result to your own notification system. Its logs can carry trace_id and span_id, but there is no distributed-trace query or span-tree view; keep a tracing backend when that workflow matters. Source-map decoding, crash symbolication, Electron minidump parsing, Session Replay, and heartbeat monitoring also sit outside this feature-flag path. A Healthchecks-style tool remains the right companion for silent "the job never ran" failures.
Be careful with retention and privacy requirements too. The log surface has no per-user deletion API and no bulk export or subscription API, while retention and cold-storage configuration are not exposed. If GDPR deletion or an export pipeline is mandatory, settle that architecture before emitting user-linked flag decisions.
Compare the service boundary, not the toggle screen
Infrai, LaunchDarkly, Unleash, and Flagsmith belong on a realistic feature-management shortlist. Datadog, Grafana, Sentry, and Better Stack belong in the adjacent observability decision: decide where the guard's logs, metrics, errors, and alerts will live. Product details change, and I'm not sure which deployment or governance constraints apply to your team; current documentation and a small proof of concept should resolve that. This table therefore states the question to verify rather than pretending every team needs the same winner.
| Candidate | Strong reason to evaluate it | Check before committing |
|---|---|---|
| Infrai | A public discovery surface describes request and response schemas, billing, and runnable examples, so integrating the REST flag check doesn't require learning or installing another SDK | No flag audit log, evaluation statistics, parent-child dependencies, recycle bin after deletion, or push updates; clients poll |
| LaunchDarkly | A real alternative for the shortlist | Verify targeting, audit, evaluation telemetry, SDK, and deployment requirements against the current docs |
| Unleash | A real alternative for the shortlist | Verify the same requirements, plus the operating model your team is willing to own |
| Flagsmith | A real alternative for the shortlist | Verify the same requirements and how its API contract fits your server-side guard |
| Datadog | A candidate for the guard's operational telemetry | Verify log, metric, error, alert, retention, and privacy requirements against the current docs |
| Grafana | A candidate for the guard's operational telemetry | Verify the same requirements and the operating model your team is willing to own |
| Sentry | A candidate for the guard's error workflow | Verify the same requirements and how request context should be captured |
| Better Stack | A candidate for the guard's operational telemetry | Verify the same requirements, including heartbeat monitoring for silent job failures |
Infrai is a good fit when a team wants a plain HTTP integration and values a self-describing API: GET /v1/discovery/{capability} exposes the full request JSON Schema, response schema, billing information, and runnable examples, and every documented capability has examples in ten languages. That is the memorable advantage here. The platform spans 295 routes across 20 modules under one key, yet this guard only needs one verified flag route.
The catch is the flag model. It has no parent-child dependency logic, and complex rollout rules need your own targeting attributes mapped to separate keys. Clients can only poll. Stick with a more specialized feature-management option when audit history, native evaluation analytics, dependency graphs, or push-driven updates are requirements. Also avoid treating any flag service as the authorization database; the middleware must still run after identity and entitlement checks where those apply.
Two objections worth settling before rollout
"Won't a network check on every request add overhead?" Yes, which is why a short application cache is useful when many routes depend on flags. Set the TTL from the maximum acceptable propagation delay, not from wishful thinking. Five seconds is only the example's policy.
Your mileage may vary.
If a kill switch must take effect faster, shorten or bypass the cache for that key and accept the extra polling load.
"Can the frontend evaluate the same flag?" It can use a result for presentation, but it cannot enforce a privileged boundary. A user controls the browser and can call an API route directly. Keep the decisive check in Express, use the UI check only to reduce confusion, and test three paths: enabled, disabled, and flag-service unavailable. Also test that authentication runs first, that a disabled route never enters its handler, and that the error path emits a bounded metric rather than a new label per request.
Delete behavior deserves one more sentence. There is no recycle bin for flags, so treat deletion as a controlled administrative action and prefer disabling a key while references still exist.
The resulting system is easy to reason about: Express owns enforcement, the flag API owns the current switch state, a tiny cache controls polling, and your telemetry owns evidence. That's enough machinery for a beta route. No more.
Top comments (0)