DEV Community

ChrysostomHayes8537
ChrysostomHayes8537

Posted on

Node.js Entitlement-Aware Feature Gating: Read Tier at Startup, Expose Flags

Short answer

Short answer: load the account tier once during Node.js startup, turn it into a small immutable flag snapshot, and attach that snapshot to every event-processing context. Keep the raw entitlement and the derived flag together so a media event can be billed against the tier that was actually in force, even when the platform is recovering from an outage.

A boolean check buried in a request handler looks cheaper. It is not. During a replay, the same event can cross a plan change and receive a different answer, which makes billing attribution impossible to explain later.

The constraint is attribution, not the toggle

Our useful unit is an account, not a process. An account has a plan and quota, while the Node.js worker has a startup time and a stream of platform events. Those clocks drift. If the worker reads a plan for every event, a retry at 09:02 can see a different tier than the original delivery at 09:01. If it reads once and never records the value, an outage replay has no evidence for the decision.

The practical compromise is a startup snapshot plus an explicit version in the event record. The snapshot answers “what may this worker do?” The event record answers “what entitlement did we use for this billable action?” Those are related, but they are not the same field.

I initially treated the flag map as configuration. Then a replay showed why that was wrong: configuration can be replaced, while an invoice needs a durable explanation. Keep the map small, typed, and easy to log.

How should Node.js read a tier at startup and expose feature flags?

Read the account document before accepting work. Fail closed for paid capabilities, but keep the process observable so an operator can distinguish “plan does not include export” from “the account service is unavailable.” The following example uses a generic HTTP endpoint and a local adapter; it does not assume a vendor SDK.

type Tier = "free" | "team" | "enterprise";

type Entitlement = {
  accountId: string;
  tier: Tier;
  quotaPerHour: number;
  revision: string;
};

type FeatureFlags = {
  canTranscode: boolean;
  canExport: boolean;
  hourlyQuota: number;
};

export async function loadEntitlements(accountId: string): Promise<Entitlement> {
  const response = await fetch(`https://accounts.example.test/v1/account/tier`);
  if (!response.ok) throw new Error(`entitlement read failed: ${response.status}`);
  return (await response.json()) as Entitlement;
}

export function flagsFrom(entitlement: Entitlement): FeatureFlags {
  return {
    canTranscode: entitlement.tier !== "free",
    canExport: entitlement.tier === "enterprise",
    hourlyQuota: entitlement.quotaPerHour
  };
}

export async function startWorker(accountId: string) {
  const entitlement = await loadEntitlements(accountId);
  const flags = Object.freeze(flagsFrom(entitlement));
  return { entitlement, flags };
}
Enter fullscreen mode Exit fullscreen mode

The endpoint, response shape, and failure policy belong behind the adapter. That keeps business code portable and makes a contract test possible. It also avoids silently treating a missing field as false, which can undercount a paid action.

For each incoming event, copy accountId, entitlement.revision, and the selected flag into the billing-attribution record. Do not recalculate from environment variables halfway through a batch. A process restart is the clean boundary for a new snapshot; a controlled refresh can be added later if your contract defines how in-flight events are handled.

Decision point Snapshot at startup Read on each event
Replay attribution Deterministic with a stored revision Can change after a plan edit
Outage behavior Queue can drain from the ledger Every retry depends on the account read
Best fit Audited media billing Minute-by-minute entitlements

The outage path needs a ledger.

A queue retry is not a new entitlement decision. Persist the attribution envelope before the side effect, then make the side effect idempotent on the platform event ID. During recovery, replay reads the envelope rather than asking today’s plan what yesterday’s event meant.

A minimal envelope can look like this:

type Attribution = {
  eventId: string;
  accountId: string;
  tierAtDecision: Tier;
  entitlementRevision: string;
  feature: "transcode" | "export";
  units: number;
  decidedAt: string;
};

function makeAttribution(
  eventId: string,
  entitlement: Entitlement,
  feature: Attribution["feature"],
  units: number
): Attribution {
  return {
    eventId,
    accountId: entitlement.accountId,
    tierAtDecision: entitlement.tier,
    entitlementRevision: entitlement.revision,
    feature,
    units,
    decidedAt: new Date().toISOString()
  };
}
Enter fullscreen mode Exit fullscreen mode

This is deliberately boring. Boring records are easy to reconcile. The ledger should answer which revision authorized the work, how many units were consumed, and whether a retry reused the same event ID. A dashboard that only shows the current tier cannot answer those questions.

What should be tested before shipping the flag design?

Test the boundaries, not just the happy path. Contract-test the entitlement response, including an unknown tier and a quota of zero. Start a worker with a known revision, change the account plan, then replay an old event and assert that its attribution still contains the original revision. Send the same event twice and verify one billable record.

Then measure startup latency, queue age while the account read is pending, and the percentage of events with missing attribution fields. For a replay drill, freeze a worker on revision r17, accept three media events, change the account tier, stop the account read for ten minutes, and release the queue. The expected result is that all three events retain r17, each event ID produces one ledger row, the retry counter rises without creating a second charge, and the operator can explain every denied export from the stored flag snapshot. That sequence is longer than a unit test, but it exercises the exact boundary that causes billing disputes. Your own workload decides the refresh interval; I am not sure a periodic refresh is safe until you can state what happens to events already accepted under the previous snapshot.

The trade-off is intentional. A startup snapshot can be stale, and a live read can increase latency and make an outage harder to recover from. Choose live reads when entitlements change minute by minute and billing can tolerate a second decision source. Stick with a snapshot-plus-ledger design when auditability and deterministic replay matter more than instant plan changes.

A concrete test uses HTTP 429 at a quota boundary. That is a policy result, not an excuse to drop the attribution record. I've seen teams log only the rejection and lose the evidence needed to reconcile a bill.

Ship it.

References

Top comments (0)