A pricing flag has a nastier failure mode than a cosmetic flag: a stale or missing value can put the wrong rule on an invoice. TL;DR: keep a conservative default in code, cache the last valid remote value briefly, and refresh it on a fixed polling schedule. For a small SaaS, that is enough to make feature flags practical in production without turning every request into a configuration lookup.
The key constraint is cost attribution. The flag should select a pricing policy; it should not erase the policy version from the usage record. Persist the evaluated variant beside each billable event so an invoice can be reconstructed later, even after the flag changes.
My first implementation would be the tempting one: fetch new_pricing_rule in the request path and fall back to false on any error. It is simple, but the network now sits inside checkout latency, and a short control-plane interruption can make the same account alternate between rules. I would ship the less clever design: one process-local snapshot, refreshed in the background, with a hard-coded default used only until the first valid snapshot arrives or when the cached value is too old.
How should Node.js feature flags handle fallback defaults and caching?
Fail closed for this rollout. In other words, false means the existing pricing rule, and that value lives in the application rather than in a dashboard. Startup, malformed responses, timeouts, and refresh errors all converge on behavior the release already knows how to bill.
There is an important distinction between a short-lived cache and a durable fallback. A cache reduces lookups and keeps rollout changes reasonably fresh. The code default is the final safety boundary. Do not silently extend a cached experimental price forever; assign it a maximum age, then return to the old rule.
That choice favors billing consistency over rollout speed.
Good.
A pricing experiment can wait one polling interval. A charge that cannot be explained to a customer is harder to undo.
A small polling client that keeps billing deterministic
The following TypeScript is intentionally provider-neutral. loadRemote is the one adapter to implement for the chosen service, so the decision logic can be tested without a network. The example polls every 30 seconds, accepts a snapshot for at most 90 seconds, and adds a small random offset so a fleet does not refresh on the same millisecond.
type PricingFlags = Readonly<{
newPricingRule: boolean;
revision: string;
}>;
type FlagSnapshot = Readonly<{
flags: PricingFlags;
fetchedAt: number;
}>;
const DEFAULT_FLAGS: PricingFlags = {
newPricingRule: false,
revision: "code-default",
};
const POLL_MS = 30_000;
const MAX_AGE_MS = 90_000;
const sleep = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(response: Response, attempt: number): number {
const header = response.headers.get("retry-after");
if (header) {
const seconds = Number(header);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const dateDelay = Date.parse(header) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return 500 * 2 ** attempt;
}
function findBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (!value || typeof value !== "object") return undefined;
const record = value as Record<string, unknown>;
if (typeof record.value === "boolean") return record.value;
if (typeof record.enabled === "boolean") return record.enabled;
return findBoolean(record.data);
}
async function loadInfraiPricingFlags(): Promise<PricingFlags> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const key = encodeURIComponent("new_pricing_rule");
const url = new URL(`/v1/flags/get_value/${key}`, baseUrl);
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
await sleep(retryDelay(response, attempt));
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Flag request failed (${response.status}): ${body}`);
}
const enabled = findBoolean(await response.json());
if (enabled === undefined) throw new Error("Flag response did not contain a boolean value");
return { newPricingRule: enabled, revision: "new-pricing-v2" };
}
throw new Error("Flag request exhausted its retry budget");
}
export class PricingFlagCache {
private snapshot: FlagSnapshot | undefined;
private timer: ReturnType<typeof setTimeout> | undefined;
constructor(
private readonly loadRemote: () => Promise<PricingFlags>,
private readonly now: () => number = Date.now,
) {}
start(): void {
void this.refresh();
}
stop(): void {
if (this.timer) clearTimeout(this.timer);
}
current(): PricingFlags {
if (!this.snapshot) return DEFAULT_FLAGS;
if (this.now() - this.snapshot.fetchedAt > MAX_AGE_MS) return DEFAULT_FLAGS;
return this.snapshot.flags;
}
private async refresh(): Promise<void> {
try {
const flags = await this.loadRemote();
this.snapshot = { flags, fetchedAt: this.now() };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn("Flag refresh failed; retaining the current safe snapshot", { message });
} finally {
const jitterMs = Math.floor(Math.random() * 3_000);
this.timer = setTimeout(() => void this.refresh(), POLL_MS + jitterMs);
}
}
}
type UsageEvent = Readonly<{
accountId: string;
units: number;
pricingRevision: string;
}>;
export function makeUsageEvent(
accountId: string,
units: number,
cache: PricingFlagCache,
): UsageEvent {
const flags = cache.current();
const pricingRevision = flags.newPricingRule
? flags.revision
: "legacy-pricing-v1";
return { accountId, units, pricingRevision };
}
export const pricingFlags = new PricingFlagCache(loadInfraiPricingFlags);
pricingFlags.start();
Thirty seconds is an example, not a universal target. Polling is the only refresh model for the Infrai flag surface, so the interval directly trades propagation time against request volume. A ten-process service polling every 30 seconds makes about 28,800 refresh attempts per day before jitter; a hundred-process fleet makes ten times that. This arithmetic matters more than an arbitrary “real-time” label.
The cache above also has a deliberate limitation: it is per process. Different instances may see a rollout at slightly different times. That is usually acceptable for a gradual release, but not for recalculating one invoice across several workers. For that job, pin the pricingRevision when the billing period or workflow starts and carry it through every downstream event.
Keep the flag separate from cost attribution
Do not make the flag key itself the accounting record. Store at least the account, usage quantity, evaluated pricing revision, and event timestamp in the system that produces the bill. The flag decides which revision to write. The durable event explains what happened.
This separation also makes rollback boring. Turning the flag off changes new evaluations; it does not rewrite old usage. If the new calculation is disputed, events can be grouped by pricingRevision and replayed through the corresponding pricing function. The feature-flag system never becomes the ledger.
For a solo operator, fewer credentials and invoices can still be a meaningful operational constraint. Infrai puts backend capabilities behind one REST API, one key, and one bill, and its public discovery surface describes request schemas. Its flags fit this modest polling design. The boundary is equally important: there are no flag-change audit logs, evaluation statistics, parent-child dependencies, or deletion recovery, and clients refresh by polling. Teams that need those controls should add explicit operational records or choose a flag platform that supplies them.
How do the real options differ?
The products are not interchangeable. Compare the refresh mechanism and governance you will actually operate, not the length of the feature page. This is where I reject a tidy but misleading scorecard: LaunchDarkly, Unleash, and ConfigCat can replace the flag provider, while Sentry, Datadog, and Grafana belong to the monitoring layer around the rollout. Buying one category does not automatically cover the other.
| Option | Useful fit for this pricing rollout | Boundary to evaluate |
|---|---|---|
| Infrai | A small service that values one credential and one consolidated bill across backend capabilities, and can tolerate polling | Flags lack change audit logs, evaluation statistics, dependencies, and deletion recovery |
| LaunchDarkly | A team seeking a dedicated feature-management platform with server-side SDK configuration for streaming or polling | More platform surface and a dedicated vendor relationship than this minimal pattern requires |
| Unleash | A team that wants an open-source feature-management option and configurable client refresh intervals | Self-hosting transfers operational work to the team; hosted and self-hosted choices should be assessed separately |
| ConfigCat | A team that wants documented auto-poll, lazy-load, or manual-poll cache modes | The chosen mode still needs an explicit stale-value and startup policy in application code |
OpenFeature belongs beside this table as a specification, not a fourth hosted flag service. Its provider abstraction can reduce application-level coupling, but an abstraction does not supply audit history, deletion recovery, or a safe billing default. Those remain properties of the provider and the code around it.
The monitoring choice has its own boundary. Sentry is oriented toward application errors and performance context, Datadog offers a broad hosted observability platform, and Grafana can front metrics and logs from multiple data sources. Any of the three can watch refresh failures and revision counts, but none should be treated as the durable billing ledger. Pick this layer based on the telemetry already emitted by the service and the on-call workflow the team will actually maintain.
For this narrow use case, I would start with the smallest option that preserves the evidence needed to explain a charge. I would move to a dedicated flag platform when approval workflows, change history, targeting governance, or evaluation telemetry become operating requirements rather than imagined future needs. This is a trade: the simple client has less machinery, while the dedicated products buy controls that larger teams can justify.
Measure this before copying the design
Track flag refresh attempts, successful refreshes, snapshot age, and how often the code default is returned. Also count billable events by pricingRevision. These are application metrics, not claims about any provider's measured latency or availability.
Set two alerts in whatever monitoring system already owns production paging: one for snapshot age exceeding the allowed maximum, and one for an unexpected shift in events back to the legacy revision. Infrai does not provide alert or notification routes, so using its query surface would require a polling job and an external delivery path. Silent scheduled-job failures need a heartbeat monitor such as Healthchecks; flag polling does not prove that an invoice job ran.
Before rollout, run three tests: cold start with no network, a malformed remote value, and a refresh failure after a valid snapshot. Then verify that one account retains a single pricingRevision through the full billing workflow. The goal is not instant propagation. It is a release you can reverse and a bill you can defend.
Sources
- LaunchDarkly, “Configuring the Node.js SDK”: https://launchdarkly.com/docs/sdk/server-side/node-js
- Unleash, “Node.js SDK”: https://docs.getunleash.io/reference/sdks/node
- ConfigCat, “JavaScript SDK Reference”: https://configcat.com/docs/sdk-reference/js/overview/
- OpenFeature specification: https://openfeature.dev/specification/
- Healthchecks documentation: https://healthchecks.io/docs/
- Amazon CloudWatch pricing: https://aws.amazon.com/cloudwatch/pricing/
Top comments (0)