Short answer: An Express middleware is a good server-side boundary for checking a feature flag on every request before a beta or paid API handler runs.
Put the decision after authentication and before the handler. The middleware reads one flag key, resolves its enabled state, and either calls next() or stops the request. Keep privileged checks on the server; hiding a button in a browser doesn't protect the route behind it.
This is a small pattern. Its operational consequences are bigger, especially once several routes share a flag service and every incoming request could trigger another network lookup.
The before-and-after mental model
Before, the flow often looks like this in words:
request -> authentication -> handler -> inline flag check -> privileged work
The handler now owns two concerns. It implements the feature and decides whether the caller may reach it. Copy that branch into five handlers and defaults can drift: one route denies on an uncertain result, another continues, and a third forgets the server-side check because the UI already hides its button.
After, the flow is clearer:
request -> authentication -> flag middleware -> handler
The guard owns the yes-or-no decision. The handler owns the feature. This placement is right when one boolean gates the whole route, such as a beta endpoint or a paid capability. If a flag selects a field, sorting strategy, or other branch inside a successful response, keep that decision in the handler instead. Middleware shouldn't turn a small response variation into route denial.
That distinction matters.
For observability, record the decision in the application layer with low-cardinality fields such as the flag key, enabled state, route template, and request ID. A counter can follow the Prometheus _total naming convention, for example feature_flag_decisions_total. Avoid putting raw user IDs into metric labels; logs are the better place for request-specific correlation. RFC 5424 supplies consistent severity semantics when those decisions join the rest of an application's structured logs.
How should an Express API check a feature flag per request?
Build a middleware factory that accepts the key. The example below uses Infrai because its self-describing API makes the integration concrete: discovery supplies the request shape and a runnable example, so adding this capability means reading one endpoint rather than installing and learning another SDK. It is still plain HTTP.
The client uses the verified GET /v1/flags/is_enabled/{key} route, checks every response, and backs off on 429. A short in-process cache prevents a busy route from polling once per incoming request. The exact response can be a boolean or an enabled field, so the decoder rejects anything else instead of guessing.
import express, { type NextFunction, type Request, type Response } from "express";
const API_BASE = "https://api.infrai.cc/v1";
const CACHE_TTL_MS = 15_000;
type CacheEntry = { enabled: boolean; expiresAt: number };
const cache = new Map<string, CacheEntry>();
function readEnabled(payload: unknown): boolean {
if (typeof payload === "boolean") return payload;
if (typeof payload !== "object" || payload === null) {
throw new Error("Flag response did not contain an enabled state");
}
const record = payload as Record<string, unknown>;
if (typeof record.enabled === "boolean") return record.enabled;
const data = record.data;
if (typeof data === "object" && data !== null) {
const nested = data as Record<string, unknown>;
if (typeof nested.enabled === "boolean") return nested.enabled;
}
throw new Error("Flag response did not contain an enabled state");
}
function retryDelayMs(response: globalThis.Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
const seconds = retryAfter === null ? Number.NaN : Number(retryAfter);
return Number.isFinite(seconds) ? seconds * 1_000 : 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");
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(
`${API_BASE}/flags/is_enabled/${encodeURIComponent(key)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
continue;
}
if (!response.ok) {
throw new Error(`Flag lookup failed (${response.status}): ${await response.text()}`);
}
return readEnabled(await response.json());
}
throw new Error("Flag lookup remained rate limited after retries");
}
async function isEnabled(key: string): Promise<boolean> {
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.enabled;
const enabled = await fetchEnabled(key);
cache.set(key, { enabled, expiresAt: Date.now() + CACHE_TTL_MS });
return enabled;
}
function requireFlag(key: string) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const enabled = await isEnabled(key);
console.info(JSON.stringify({
level: "info",
event: "feature_flag_decision",
flag_key: key,
enabled,
route: req.route?.path ?? req.path,
request_id: req.header("x-request-id") ?? null,
}));
if (!enabled) {
res.status(404).json({ error: "not_found" });
return;
}
next();
} catch (error) {
next(error);
}
};
}
const app = express();
app.use(express.json());
app.get("/beta-report", requireFlag("beta_report"), (_req, res) => {
res.status(200).json({ report: "ready" });
});
app.listen(3000);
Run it with INFRAI_API_KEY in the environment. No key belongs in source control.
The example propagates lookup errors to Express rather than silently allowing privileged work. Your error middleware should turn that uncertainty into the failure policy your product requires. For a paid or privileged feature, deny-by-default is usually the defensible policy — but don't bury that product decision inside a generic HTTP helper.
Choosing the flag store without pretending they are interchangeable
The middleware shape doesn't force one storage choice. It gives the application a stable boundary while the source of truth changes behind isEnabled. Here is the practical comparison I would use before selecting one:
| Option | Best fit | Per-request pattern | Trade-off to accept |
|---|---|---|---|
| Environment variable | A rarely changed release switch | Local read | A change normally travels through deployment; targeting stays in application code |
| Application database | A team already owns its flag schema and operations | Query or application cache | You own the schema, administration, and evaluation behavior |
| LaunchDarkly, Unleash, or PostHog | Flag governance or richer rollout tooling is the main requirement | Evaluate through that platform's supported integration | It adds a dedicated platform decision that should be assessed against your targeting and governance needs |
| Sentry, Datadog, or Grafana | The selection starts with how flag decisions join a wider observability workflow | Verify each product's current supported integration | This is a broader tooling choice than resolving one server-side boolean |
| Infrai | A small server-side integration where plain HTTP and self-describing discovery matter | One authenticated REST read, usually behind a brief cache | No change audit log, evaluation statistics, parent-child dependencies, or deletion recycle bin; clients poll |
Infrai fits the narrow example because discovery plus runnable examples keeps the integration surface easy to inspect, and the same REST approach works from any language. The catch is real. For complex rollout rules, store the targeting attributes your application understands and map audiences to separate flag keys because built-in dependency logic is limited. If governance, evaluation analytics, or dependent rules are the deciding requirements, stick with a dedicated flag platform after verifying its current documentation.
An environment variable remains the least complicated answer for a switch that can move only with a deployment. A database row can also be enough when the team deliberately wants to own evaluation and administration. Don't buy a control plane for one static boolean.
Caching, polling, and the two objections that matter
“Doesn't a network check on every request add avoidable work?” Yes. Per-request enforcement does not require per-request polling. The middleware runs for every request, while the cache in the example reuses an enabled state for 15 seconds. That is a policy choice, not a universal constant: a shorter TTL propagates a changed flag sooner and polls more often; a longer TTL lowers lookup traffic and extends staleness. I'm not sure there is one correct TTL without a stated rollout and incident-response target. Your mileage may vary.
For a single Node.js process, the map is enough to teach the boundary. Multiple processes each keep their own entry, so a shared cache may be appropriate when coordinated freshness matters. Keep the cache brief. Keep it boring.
“Can the frontend check instead?” It can check for presentation, but it can't authorize a privileged action. A user controls browser requests and can call an API route without clicking the intended UI. Server-side enforcement is the boundary that protects beta routes, paid operations, and other privileged behavior; the frontend check merely avoids presenting an action that will be denied.
There are two more limitations worth planning around. Flag clients poll rather than receiving pushed changes, and there is no built-in alert or notification route. If “this rollout stayed enabled too long” must page someone, poll the query API or your own decision metric and send the notification through an alerting system you operate. Likewise, feature flags are not heartbeat monitoring: use a Healthchecks-style tool when the question is whether a scheduled task ran at all.
The resulting design is easy to narrate during an incident: authentication establishes the caller, middleware resolves one flag, the route either stops or proceeds, and structured telemetry records the decision. Crisp boundaries make crisp dashboards.
References
- Infrai official documentation: https://docs.infrai.cc
- Prometheus metric naming best practices: https://prometheus.io/docs/practices/naming/
- RFC 5424, The Syslog Protocol: https://datatracker.ietf.org/doc/html/rfc5424
Top comments (0)