Short answer: treat a feature-flag read as a local reliability decision, not a remote request: serve the last validated configuration from memory, define a consequence-specific startup default, poll out of band with jitter, and emit one bounded decision signal only when it can explain a notification delivery failure.
That answer matters in a fintech notification service because the flag system and the delivery system fail on different clocks. A payment alert may need a decision now, while a control-plane refresh can wait. Coupling those clocks turns a configuration delay into customer-facing latency; logging every evaluation avoids that coupling but creates a different failure, an observability stream whose volume and label cardinality can obscure the delivery failures the team actually needs to find.
The design target is therefore not maximum telemetry. It is enough evidence to distinguish four states: fresh configuration, stale-but-accepted configuration, an explicit startup default, and an invalid refresh that was rejected. Everything else should earn its storage.
How should a Node.js SaaS choose feature flag fallback defaults?
A single global fallback such as false looks safe, but safety depends on the consequence of the flag. Consider two controls in the same notification service. One pauses retry traffic during a downstream capacity event. Another enables a secondary delivery path. Defaulting both to false may suppress a useful secondary path while allowing retry pressure to continue. The mechanically consistent answer is not operationally consistent.
Define the fallback beside each flag as a small policy record with three independent decisions: the startup value used before any valid configuration exists, the maximum age for the last known good value, and the action taken after that age. This forces the reviewer to discuss consequence rather than argue about a universal Boolean. It also gives tests stable boundaries.
For a concrete design example, suppose the service handles payment receipts, transfer alerts, and security notices. These are illustrative workload assumptions, not measured production results. A control that changes message wording may tolerate a 30-minute cached value. A control that suppresses duplicate sends may warrant a much shorter acceptance window because its incorrect value can affect recipients directly. The exact windows should come from the service's risk owner and incident objectives; I'm not sure a generic number can answer that policy question, and copying one from another system would hide the decision rather than solve it.
Keep the states explicit:
| Evaluation state | Decision source | Delivery-path behavior | Telemetry action |
|---|---|---|---|
| Fresh | Validated in-memory snapshot | Use evaluated value | Count decisions by bounded dimensions |
| Stale but accepted | Last known good snapshot within its age limit | Use evaluated value | Count stale use; record snapshot age in a histogram |
| No valid snapshot | Declared startup policy | Use the reviewed default | Emit one state transition and count affected outcomes |
| Refresh rejected | Previous validated snapshot | Preserve the previous decision | Count the rejection by a small reason class |
Never replace a validated snapshot in place field by field. Build a candidate, validate its shape and required values, then swap the whole snapshot. A request should observe one coherent version. This is an architectural invariant, not a vendor feature.
Short paths win.
Separate the request path from polling and cache freshness
The evaluation path should perform a memory lookup against an immutable snapshot. It should not wait for the next poll, retry a configuration fetch, or turn a refresh deadline into notification latency. The polling loop owns network work; the request path owns a deterministic decision from state already present in the process.
For multiple Node.js instances, give each process its own in-memory snapshot and add jitter to the polling interval. If every instance starts at the same second and polls on the same fixed cadence, a routine deployment creates synchronized load on the configuration source. Jitter changes the phase, while a capped backoff limits repeated refresh work after a failed attempt. Neither mechanism changes the maximum accepted snapshot age, which remains a policy constraint rather than a retry side effect.
Caching needs two clocks. Record when the source says the configuration version was produced, if that field exists, and separately record when this process accepted it. The first helps operators reason about source age; the second determines the local freshness policy. A wall-clock timestamp alone is vulnerable to clock adjustments when used to measure elapsed time, so the implementation should use a monotonic duration for local age checks. The exported telemetry can still use ordinary timestamps for correlation.
There is a subtle boundary here. A poll interval controls how soon a process normally learns about a change; it does not define how long an old value remains acceptable. If the interval is 20 seconds and the age limit is 10 minutes, several missed polls can still yield a valid local decision. If the age limit is also 20 seconds, routine jitter may push a process directly into fallback. Those are radically different semantics even though a dashboard might label both configurations “20-second polling.”
The cache should retain the last valid snapshot, its bounded version identifier, its acceptance time, and the next scheduled refresh. It doesn't need a history of every fetched document in every application process. Configuration history belongs in the control plane or an audit store with an explicit retention policy; hot-path evaluation only needs the state required to make the next decision.
Budget telemetry before choosing events and labels
Start with the questions an operator must answer during a delivery incident. Did failures rise only for decisions made from a fallback? Are stale decisions concentrated in one deployment region? Did one notification class experience a different outcome? That set suggests a counter with bounded attributes such as decision state, notification class, region, and delivery outcome, plus a histogram for accepted snapshot age. It does not justify attaching account ID, payment ID, recipient, raw flag payload, or an unconstrained error string.
Cardinality multiplies. In the illustrative service, 4 decision states x 3 notification classes x 4 regions x 5 outcome classes creates at most 240 combinations before deployment dimensions. Add 50 process IDs and the theoretical space becomes 12,000. Add 100,000 tenant IDs and it becomes 1.2 billion. Not every combination will occur, but the multiplication exposes which label makes the design economically and operationally unstable.
Count the bounded dimensions. Keep the identifiers out.
Logs need a similar budget. A process that evaluates 2,000 flags per second and writes a 300-byte record for every decision would produce about 51.84 GB per day before indexing overhead: 2,000 x 300 x 86,400. That is a transparent planning estimate from stated assumptions, not a benchmark. CloudWatch publishes log ingestion charges per GB and notes that pricing varies by region and usage tier, so byte volume is part of the architecture even before a team selects a backend. Sampling the routine success path at 1% would change the illustrative raw event volume by two orders of magnitude, but it must not be presented as a universal setting.
Sampling is only useful when it preserves the rare states. Keep state transitions, validation rejections, and fallback activations unsampled because their rate should already be low. Sample repetitive fresh-success evaluations if trace-level examples are needed. Aggregate the full population into counters so rate calculations do not depend on reconstructing sampled logs. For delivery failures, retain enough correlation to connect the decision state to the outcome without placing sensitive business identifiers in metric labels; a trace or access-controlled event can carry a pseudonymous correlation value when investigation requires one.
Retention follows the same logic. High-rate sampled success events can have a short diagnostic window, while low-rate state changes may deserve a longer window for audit and incident reconstruction. The catch is that longer retention does not repair a missing field, and more fields do not repair an unbounded schema. First decide which decision can be made from the data. Then pay to keep it for the period in which that decision is still actionable.
What should production tests prove about caching and polling strategy?
Unit tests should advance a controllable clock through the exact boundaries: no snapshot, fresh snapshot, the last accepted instant, and the first expired instant. Property tests can verify that an invalid candidate never replaces the prior valid snapshot. A concurrency test should run evaluations while snapshots are swapped and assert that no result combines fields from two versions.
Then test the polling loop as a state machine rather than waiting on real timers. It should schedule the next attempt with bounded jitter, cap its retry delay, accept only a complete valid candidate, and leave request evaluation independent of refresh completion. Tests should also confirm telemetry restraint: one refresh rejection increments one bounded counter, a transition into fallback creates one transition event, and 10,000 routine evaluations do not create 10,000 mandatory log records.
Deployment tests need a different shape. Roll a candidate policy to one internal notification class, compare delivery outcomes by decision state, and verify that metric dimensions remain within the predicted set. A shadow evaluation can calculate the proposed value without changing delivery behavior; record only aggregate disagreement counts by bounded class. This detects a dangerous default before it controls a customer message while avoiding a duplicate event stream full of recipient data.
No strategy is suitable everywhere. Stick with a push or streaming configuration channel when the required propagation bound is tighter than a practical polling interval, but preserve the same local snapshot and fallback semantics. Use a centralized decision service when policy demands one globally serialized decision point and the added request dependency has been explicitly accepted. Polling plus process-local caching is strongest when low request latency and tolerance of brief control-plane disconnection matter more than immediate global convergence.
Roll out the policy in four measured steps
First, inventory every flag that can alter notification delivery and assign an owner, startup default, maximum accepted age, and expired-state action. Reject entries that have no consequence analysis.
Second, introduce the immutable local snapshot behind the existing evaluation interface. Observe freshness and validation counters before changing any delivery result. This separates cache mechanics from business-policy risk.
Third, shadow the new defaults for one bounded notification class and compare aggregate disagreements and delivery outcomes. Expand by class, not by a random mix of all messages, so rollback has a clear operational meaning.
Finally, remove per-evaluation logs once the counters, transition events, and sampled diagnostic records answer the incident questions. Recalculate daily bytes and active label combinations after each expansion. The rollout is complete when the service can explain a delivery failure from bounded signals without depending on the flag control plane at request time.
Top comments (0)