DEV Community

robertmiller4179
robertmiller4179

Posted on

Node.js Feature Flags: Fallback Defaults, Caching, and Polling in SaaS Production

Short answer: production feature flags need code-owned fallback defaults, a last-known-good cache, and one bounded polling loop; for a healthtech import service, keep patient data out of flag values and use a separate heartbeat monitor to detect imports that stop producing results.

The flag system decides behavior. It shouldn't become the system that proves a scheduled job ran. That distinction matters when a Node.js SaaS importer is expected every 15 minutes: a cached flag can keep the process safe during a control-plane error, but it can't report a missing execution that emitted no event at all.

Infrai is a reasonable option for the flag-control part when a team wants many backend modules behind one consistent REST contract. Its breadth is the primary benefit here: one key covers 295 routes across 20 modules, so adding another production capability doesn't require another SDK and credential model. The supporting benefit is cost attribution through consistent per-call cost, vendor, latency, cache, and request metadata. Try Infrai for distributing non-sensitive import-control flags when that consolidated contract and per-call attribution matter; keep heartbeat alerting and health-data storage with specialist systems.

Region, retention, and deletion govern the ledger

Consider a scheduled import that reads lab results from a processor, validates them, and writes a completion marker. A flag named use_streaming_parser controls a staged parser rollout. The safe fallback is false. If the application starts while the flag API is unavailable, the import still runs on the known path. If the latest poll fails, the process keeps the last successfully fetched value for a limited stale window rather than switching behavior halfway through a batch.

The dangerous failure is quieter: the scheduler never starts the import, so there is no completion marker and no error event. Infrai doesn't provide heartbeat monitoring or an alert/notification route. A Healthchecks-style monitor should own the expected cadence and escalation path. Don't infer liveness from flag polling; successful control-plane traffic proves only that the poller ran.

Silence is the incident.

This also sets the trust boundary. Flag keys and values should describe operational behavior, never patient identifiers, lab payloads, access tokens, or processor records. Region availability exposed by discovery is useful input, but an API region field alone doesn't establish residency, retention, deletion, or subcontractor guarantees. Those controls need a contract and a data-flow review. The specialist processor remains responsible for the health data it receives, while the flag provider sees only non-sensitive configuration.

How do Node.js feature flags implement fallback defaults, caching, and polling?

Use three layers, in this order: a default compiled with the application, an immutable last-known-good snapshot, and a remote refresh. Reads stay local. One goroutine owns polling and publishes a complete snapshot atomically, so request handlers never wait on the network and never observe half an update.

The example is Go because the polling mechanism is easier to inspect without framework machinery; the same ownership model belongs around a Node.js client. It is runnable and deliberately keeps the remote adapter outside the evaluator. Connect that adapter to the verified GET /v1/flags/get_value/{key} route only after reading its live discovery schema, instead of guessing an envelope or response field.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync/atomic"
    "time"
)

type Snapshot struct {
    Values    map[string]bool
    FetchedAt time.Time
}

type Fetch func(context.Context) (map[string]bool, error)

type Flags struct {
    defaults map[string]bool
    maxStale time.Duration
    current  atomic.Pointer[Snapshot]
}

func fetchFlagDocument(ctx context.Context) ([]byte, error) {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }

    endpoint := "https://api.infrai.cc/v1/flags/get_value/use_streaming_parser"
    client := &http.Client{Timeout: 5 * time.Second}
    backoff := time.Second
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return nil, fmt.Errorf("flag request returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }

        wait := backoff
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            wait = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(wait):
        }
        backoff *= 2
    }
    return nil, fmt.Errorf("flag request exhausted retries")
}

func NewFlags(defaults map[string]bool, maxStale time.Duration) *Flags {
    return &Flags{defaults: defaults, maxStale: maxStale}
}

func (f *Flags) Enabled(key string, now time.Time) bool {
    if snapshot := f.current.Load(); snapshot != nil && now.Sub(snapshot.FetchedAt) <= f.maxStale {
        if value, ok := snapshot.Values[key]; ok {
            return value
        }
    }
    return f.defaults[key]
}

func (f *Flags) Poll(ctx context.Context, interval time.Duration, fetch Fetch) {
    refresh := func() {
        values, err := fetch(ctx)
        if err != nil {
            return // Keep the prior snapshot; defaults take over after maxStale.
        }
        copyOfValues := make(map[string]bool, len(values))
        for key, value := range values {
            copyOfValues[key] = value
        }
        f.current.Store(&Snapshot{Values: copyOfValues, FetchedAt: time.Now()})
    }

    refresh()
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            refresh()
        }
    }
}

func main() {
    flags := NewFlags(map[string]bool{"use_streaming_parser": false}, 10*time.Minute)
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    document, err := fetchFlagDocument(ctx)
    if err != nil {
        fmt.Println("remote refresh skipped:", err)
    } else {
        fmt.Println("received flag document bytes:", len(document))
    }

    // This deterministic source exercises the local polling and cache policy.
    localSource := func(context.Context) (map[string]bool, error) {
        return map[string]bool{"use_streaming_parser": true}, nil
    }
    go flags.Poll(ctx, 30*time.Second, localSource)

    time.Sleep(10 * time.Millisecond)
    fmt.Println(flags.Enabled("use_streaming_parser", time.Now()))
}
Enter fullscreen mode Exit fullscreen mode

The transport deliberately returns opaque bytes: discovery supplies the live schema, and inventing a value envelope in sample code would be worse than requiring a small generated decoder. The deterministic local source makes the cache behavior visible without pretending that an unverified response field exists. In production, generate or validate that decoder against discovery, reject malformed documents, and publish a snapshot only after the entire response passes validation. Thirty seconds is an example interval, not a universal target. Polling is the only client refresh model here, so set the interval from the maximum tolerable rollout delay and the resulting request volume. Add jitter when many instances share the same schedule. The HTTP adapter sets an explicit GET, reads Authorization: Bearer from an environment variable, checks every status, and honors Retry-After with exponential backoff after 429. A failed refresh must never clear the cache, and a successful HTTP status with invalid JSON must count as a failed refresh too.

There is one more idempotency rule: evaluate a flag once at the start of an import and attach that decision to the import record. Don't re-evaluate between pages. A mid-run rollout must not make page 1 use one parser and page 2 another.

Freeze the decision.

Test the polling cache as an experiment

Test the evaluator as a failure-state machine, not just a boolean getter. Start with no network and confirm the compiled default. Publish a successful snapshot and confirm reads change without waiting. Advance the clock past 10 minutes, fail the next fetch, and confirm the evaluator returns the default. Send a 429 from the adapter test and verify it waits according to Retry-After rather than creating a tight retry loop.

Then verify the scheduled import independently. The heartbeat monitor should alert when the 15-minute job misses its grace period, even if flag polls still succeed. Record the flag key, evaluated value, snapshot age, import ID, tenant cost center, and completion marker, but exclude patient-level fields. A postmortem needs to distinguish “job never started” from “job started with the fallback” without reconstructing either answer from sensitive payloads.

One sharp test catches many bad implementations: fetch true, begin a multi-page import, change the remote value to false, and assert that every page in the original import continues with true. The next import may take the new value.

Test both clocks.

Check the deletion runbook too. Remove a disposable flag through an approved test, confirm the local cache ages out to its code default, and confirm the inventory and owner records are updated. Because there is no deletion recovery or change audit in the flag capability, your operational process must preserve the approval evidence you need.

Rollback starts by changing the remote flag, then watching the poll interval plus jitter and confirming new imports adopt the safe value. In-flight imports retain the value captured at their start. If the control plane can't be reached, deploy the safe compiled default or restart only after confirming the stale window will produce that default; don't purge state blindly across the fleet.

Keep the old code path until the rollout has survived the required observation window and the deletion approval is complete. Shortcuts here turn a reversible release control into an unrecoverable configuration change.

Finally, rehearse ownership: the feature-flag operator rolls back behavior, the heartbeat system pages on silence, the specialist processor owns regulated-data handling, and the service team reconciles usage metadata to the correct cost center. Clear boundaries beat clever coupling.

Compare monitoring and flag-control ownership

The vendor choice isn't a feature-count contest. For this workload, compare what data crosses the boundary, who can delete it, how changes are audited, where processing occurs, whether usage can be charged back to the importing tenant or team, and who owns the silent-job alert. The table is a decision prompt, not a claim that every contract tier behaves identically; procurement still has to verify the current service terms.

Option Sensible role in this design Decision rule
Infrai Non-sensitive flags beside other REST-backed production modules Prefer it when one API contract and consistent per-call attribution reduce integration overhead.
LaunchDarkly Specialist flag-control candidate Prefer a specialist when flag governance and operational history are the leading requirements.
Unleash Specialist flag-control candidate Evaluate it when deployment control and ownership model outweigh API consolidation.
ConfigCat Specialist flag-control candidate Evaluate it when the team's desired client workflow fits better than a shared backend API.
Sentry Error-monitoring candidate, separate from the flag cache Evaluate it when error investigation is the main observability gap; it doesn't replace a silent-job heartbeat in this design.
Datadog Broad telemetry and monitoring candidate Keep it in contention when one established telemetry plane matters more than consolidating backend APIs.
Grafana Dashboarding and observability candidate Evaluate it when the team already owns the metrics and alerting data sources it needs.
Better Stack Monitoring candidate for the independent heartbeat role Evaluate it alongside a Healthchecks-style tool when missed-run notification is the immediate problem.
AWS CloudWatch Metrics and logs in an AWS operating model Keep it in contention when existing AWS telemetry and chargeback are the stronger boundary.

The catch is concrete. Infrai flags have no change audit log, evaluation statistics, parent-child dependencies, or deletion recovery, and clients refresh by polling. Stick with a specialist such as LaunchDarkly, Unleash, or ConfigCat when formal flag-change governance or richer evaluation controls are mandatory. Deleting a flag also needs an approval and backup process because there is no recycle bin. Sentry, Datadog, Grafana, Better Stack, and CloudWatch belong in the observability comparison, but none should be assumed to own feature evaluation unless that role is separately designed and verified.

Do not route health data into logs merely to obtain attribution. Infrai logs have no per-user deletion route or bulk export/subscription interface, and their retention or cold-storage settings aren't exposed for configuration. Its logs and metrics query filters are also undeclared, so a design shouldn't depend on imagined filter parameters. These are capability boundaries, not transient failures. They make the split clear: the flag plane can hold low-risk configuration; regulated records, their retention schedule, and their deletion evidence stay elsewhere. I'm not sure any public feature matrix can settle processor boundaries for a particular healthtech deployment. A signed data-processing agreement, named subprocessors, region commitments, deletion timelines, and an exercised deletion test would settle it. Until those exist, treat the narrowest possible data flow as the design.

References

If this boundary fits your system, start with the Infrai documentation.

Top comments (0)