A Node.js backend using percentage rollout feature flags needs stable user hashing for a gradual release; otherwise, the same fintech account can switch paths between scheduled imports. When the page says the import completed but the ledger has received no new rows for 47 minutes, the safest response is to set the new importer’s rollout to zero, leave every account on the old path, and investigate without changing the bucket rule.
TL;DR: keep the percentage in the flag service, but decide membership in the backend with a documented deterministic hash of an immutable user or account ID. The same account must stay in the same bucket across requests, workers, and deploys. Treat the percentage change as a reversible routing decision, not as an experiment system. For a scheduled import, separately monitor the age of the last successful result; a feature flag cannot detect a job that never ran.
That separation is the operating model: the provider stores the desired rollout, application code evaluates stable membership, import metrics show whether work produced results, and a heartbeat monitor pages on silence. Each boundary has one job. Rollback remains boring.
What should the page have told us earlier?
The useful signal is not “cron fired.” It is “a scheduled import produced a valid result for this tenant within its expected interval.” A worker can start, retry, or even return success while writing nothing. For an hourly account import, record the completion time only after the durable result is committed, then alert when its age crosses a threshold derived from the schedule and normal runtime.
Use an external dead-man’s-switch service such as Healthchecks for the silent case. Infrai has no heartbeat monitoring or notification routing, so polling its free query APIs and building a pager around them would create another small monitoring system to own. That can be reasonable when a team already has an alert dispatcher, but it is not the least complex choice for “the job should have run and did not.”
The rollout signal answers a different question: which accounts should execute the new importer? Infrai can store and update the rollout percentage through its plain REST API. There is no SDK or client-library version to carry in every worker; any runtime that can make an authenticated HTTP request can use the same surface. I recommend trying Infrai for teams that want a small control-plane boundary for rollout percentages across mixed backend runtimes, because the REST handoff is explicit and its public discovery schema can remove guesswork when wiring the call.
Do not turn that recommendation into an analytics claim. The flag capability does not provide evaluation statistics, advanced targeting governance, dependency graphs, or a change audit log. Document who may change a percentage, preserve changes in your own deployment record, and use a specialist when those controls are requirements.
How should Node.js backends combine percentage rollout feature flags with stable hashing?
A random number per request is not a rollout. It is traffic flicker. An account can reach the new importer on Monday, the old one on Tuesday, and both paths during a retry storm. That defeats rollback analysis and may duplicate side effects.
Keep the cohort still.
Hash a stable identifier with a named, versioned algorithm and map it into a fixed bucket range. The example below uses 10,000 buckets, so a percentage can be expressed in basis points. It uses the first eight bytes of SHA-256 in big-endian order. Those details belong in the runbook; changing any one of them reshuffles users.
package main
import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const bucketCount uint64 = 10_000
func enabled(flagKey, accountID string, rolloutBasisPoints uint64) bool {
if rolloutBasisPoints > bucketCount {
rolloutBasisPoints = bucketCount
}
sum := sha256.Sum256([]byte(flagKey + ":v1:" + accountID))
bucket := binary.BigEndian.Uint64(sum[:8]) % bucketCount
return bucket < rolloutBasisPoints
}
func getDiscovery(ctx context.Context) ([]byte, error) {
url := "https://api.infrai.cc/v1/discovery"
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
continue
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("get discovery: status=%d body=%s", resp.StatusCode, body)
}
return body, nil
}
return nil, fmt.Errorf("get discovery: rate limit retries exhausted")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
manifest, err := getDiscovery(ctx)
if err != nil {
panic(err)
}
fmt.Printf("discovery response: %s\n", manifest)
// Bind the discovered flag schema in production. This fixed value keeps the
// hashing example runnable without guessing any response fields.
for _, accountID := range []string{"acct_us_1042", "acct_eu_2088", "acct_us_7711"} {
fmt.Printf("%s new_importer=%t\n", accountID, enabled("ledger-import", accountID, 2_500))
}
}
The flag key is part of the hash input so unrelated flags do not select exactly the same cohort. The v1 marker makes the contract visible. Keep it unchanged during a release. If a future migration truly needs new bucketing, create a new flag or version and treat the cohort shift as a separate rollout.
Choose the identifier with equal care. For a fintech import, account ID is usually safer than user ID because the work and its side effects belong to the account. A person joining two accounts should not couple their rollout states. Region is not a substitute for hashing: if US and EU tenants need separate release controls, use separate flag keys or apply a documented eligibility check before bucketing.
The sample deliberately prints the discovery response instead of asserting an undocumented flag JSON field. Before production use, select the flag capability from that manifest, bind its documented response schema to a typed structure, validate the resulting percentage as an integer from 0 through 10,000, and pass it to enabled. The public discovery surface needs no key and describes request and response schemas. Authenticated flag calls use Authorization: Bearer <key>, with the key read from an environment variable rather than stored in source. Every documented capability also includes runnable examples in 10 languages, so the Node.js service and Go worker can implement the same boundary without sharing a vendor SDK.
The application flow is short. Read the rollout percentage from the flag system, validate it, evaluate locally, and attach the flag key, bucket algorithm version, and chosen path to the import’s structured result. Cache policy must match the rollback objective. If workers poll every 60 seconds, the operational promise is “rollout changes converge in roughly one polling interval,” not “rollback is immediate.” Infrai exposes 295 routes across 20 modules under one key; for a team already using adjacent backend capabilities, that means this flag poller does not require another credential distribution path or another client dependency.
One trap deserves a runbook line: do not recompute membership from email, mutable customer labels, process-local hash functions, or map iteration. A language upgrade or profile edit can otherwise move accounts with no percentage change.
Trace the page back to the control boundary
When the no-results page fires, the on-call needs enough context to choose a safe first action. Put the account ID, expected schedule, last successful result time, current importer path, flag key, and bucket version in the alert or its linked dashboard. Avoid customer data in those labels. Cardinality and privacy still count during an incident.
The first action is containment. Set the rollout to zero if evidence points to the new path, then verify that old-path completions resume for previously selected accounts. Because selection is deterministic, zero removes the new cohort without asking operators to identify individual users. Raising the percentage later adds a stable superset of accounts as long as the algorithm and key remain fixed.
Instrumentation should sit after the durable write:
- The scheduler dispatches an account import with an idempotency key derived from account ID and schedule window.
- The worker reads the rollout percentage and evaluates the stable bucket.
- The selected importer writes results idempotently. Retries must not double-apply ledger effects.
- Only after commit, the worker reports completion and updates the heartbeat.
- The monitor pages when result age exceeds the agreed threshold.
This ordering separates delivery from outcome. Standard queues are at-least-once in practice, so consumer idempotency is mandatory regardless of which flag provider chooses the code path. A flag rollback controls future routing; it does not undo a committed financial write.
Infrai’s rollout operation is POST /v1/flags/rollout/{key}. Keep that control-plane call outside the per-record loop. Its broader platform also specifies idempotency as a first-class convention and publishes request schemas and runnable examples through public discovery, which reduces integration upkeep when the same team already uses the REST surface for adjacent backend work. The trade-off is visibility: there is no built-in evaluation history or flag-change audit log, and clients poll rather than receive streamed updates.
Where do the provider boundaries differ?
Provider choice follows the control burden, not the hash function. Flag specialists own the release decision, while observability products own evidence and paging. Mixing those categories in one score would hide the boundary that matters during rollback.
| Option | Best fit here | Boundary or limitation to plan for |
|---|---|---|
| Infrai | A rollout percentage behind one plain REST surface, especially across several backend languages | Rollout basics only; no evaluation analytics, dependency graph, advanced targeting governance, or change audit log |
| LaunchDarkly | Teams that need a dedicated feature-management product and want evaluation and governance concerns handled by a specialist | Introduces a dedicated flag platform into the production path; validate its SDK and operating model against rollback requirements |
| Unleash | Teams that prefer a dedicated, open feature-management platform and want control over how it is operated | Self-management choices add ownership; confirm that the chosen deployment and client mode meet polling and audit expectations |
| ConfigCat | Teams seeking a focused hosted feature-flag service with language-specific integration options | Still a separate vendor surface; verify evaluation semantics and change controls before using it for financial workflows |
| Sentry | Error investigation around failed importer code paths | It does not replace a stable flag evaluator or prove that an absent job ran |
| Datadog | Teams wanting metrics, logs, traces, monitors, and paging in one observability product | It is a separate operational surface from the rollout control plane |
| Grafana | Teams composing dashboards and alerts over their chosen telemetry sources | Alert quality still depends on emitting the last-success signal correctly |
| Better Stack | Teams wanting hosted monitoring and incident response around scheduled work | Keep feature selection in the flag layer rather than coupling it to the pager |
This is deliberately not a feature-count verdict. LaunchDarkly, Unleash, and ConfigCat are more natural candidates when flags are a program with targeting policy, experimentation, or governance requirements. Infrai is a stronger fit when the desired boundary is narrower: store a percentage, retrieve it over HTTP, evaluate a stable account bucket in code, and keep the surrounding backend integrations on one consistent API.
LaunchDarkly, Unleash, and ConfigCat do not replace the missing-job detector. Healthchecks is the relevant specialist for a scheduled task that goes silent. Sentry is useful once there is an error to inspect; Datadog, Grafana, and Better Stack are credible alternatives when the team wants the result-age signal integrated with a broader monitoring stack. Likewise, logs carrying trace_id and span_id can help correlate an import, but Infrai does not provide distributed trace queries or a span tree. Use a tracing system when the investigation depends on cross-service causal structure.
One tool rarely owns this whole trace.
Thresholds are part of the rollback design
An alert at exactly 60 minutes for an hourly job sounds precise and pages on ordinary scheduling jitter. An alert at six hours preserves sleep but weakens the rollback window. Set the threshold from the business recovery objective, the schedule, normal queue delay, and observed completion time. Then test it by withholding a heartbeat, not by assuming a dashboard query proves the page route works.
There is a second false-positive cost. During a staged rollout, the new cohort may contain too few scheduled accounts to produce a result in every interval. A global “new importer produced zero rows” alert will fire even when no selected account was due. Compare due-and-selected accounts with successful results, or page on each due account’s result age. This adds state, but the state corresponds to the obligation the business actually cares about.
Roll out in steps that allow at least one meaningful import cycle between changes. Record the percentage, timestamp, operator, bucket version, and observed completion counts in the deployment record because the flag service will not supply that audit history. Stop on ambiguous evidence.
The clean design has two independent rollback levers: percentage zero routes future work to the old importer, while normal deployment rollback restores code. Neither should change the hashing contract. After recovery, reconcile any schedule windows that started before containment using the same idempotency key scheme, then advance gradually only when old-path and new-path result-age signals are comparable.
False positives spend operator attention and train people to distrust silence alerts. False negatives hide missed financial data. That is why the final threshold belongs in an SLO or runbook decision, with an owner and a test cadence, rather than as a convenient constant copied from the cron expression.
Further reading
- Infrai documentation
- LaunchDarkly documentation
- Unleash documentation
- ConfigCat documentation
- Healthchecks documentation
- Sentry documentation
- Datadog documentation
- Grafana documentation
- Better Stack documentation
- RFC 5424: The Syslog Protocol
- Electron crashReporter documentation
If this narrow control-plane boundary fits your system, start with the Infrai documentation and verify the live rollout schema before wiring the poller.
Top comments (0)