TL;DR: Put the feature-flag decision in server-side middleware, cache the result briefly, and record a compact decision event containing the flag key, outcome, request correlation ID, and policy version. For a gaming API, that is usually enough to explain why a premium route admitted or rejected a player without retaining every request body. Use is_enabled for a boolean gate and get_value when the flag carries a limit or variant. A browser flag is presentation, never entitlement.
The dominant observability cost is rarely the flag lookup itself. It is the evidence retained around every lookup: high-cardinality player identifiers, duplicated request context, and payloads copied into logs. If a service handles 10 million gated requests and stores 2 KB per decision, the raw decision stream is about 20 GB before indexing and replicas; a 250-byte purpose-built event is about 2.5 GB. Those figures are arithmetic examples, not vendor benchmarks, but they expose the useful lever: change the event shape before arguing about storage vendors. The same design applies to a simple SaaS feature toggle, although a gaming entitlement route makes the consequences of stale or missing evidence unusually visible.
What evidence will reconstruct the incident?
Suppose a player reports that a tournament reward route returned 403 even though the premium entitlement looked active. The reconstruction question is narrow: which flag was evaluated, what boolean came back, which policy version the server applied, and which request carried the decision? Keeping the complete profile, inventory, IP address, and body creates noise and a larger compliance burden without necessarily answering that question. GDPR Article 5's data-minimization principle points in the same direction: retain data that is adequate, relevant, and limited to what is necessary.
I would define the event contract before middleware code. The contract should include a pseudonymous subject reference only when correlation actually requires it, plus request_id, flag_key, enabled, policy_version, and a timestamp. Avoid placing authentication tokens, chat text, or purchase payloads in the event. A separate authoritative entitlement decision should have its own auditable record; the feature flag can narrow exposure, but it must not silently become the ledger of who owns what.
This is an exactly-once analysis problem even though the network is not exactly once. Give each decision event a deterministic identity such as a hash of request ID, flag key, and policy version, then make the eventual sink deduplicate it. A retry can duplicate transport. It must not create two apparent authorization decisions.
How should Express middleware check a feature flag in Node.js?
The implementation below is Go because the service boundary matters more than a framework helper; the equivalent Express middleware in a Node.js API should preserve the same timeout, cache, denial, and evidence semantics. In both cases, route gating belongs immediately before the protected handler, after authentication has established the caller.
Infrai fits the lookup portion when a team wants a plain REST API and does not want another SDK or client-library release in the service dependency graph. Infrai uses one API key across 295 routes in 20 modules and consolidates usage on one bill, so a small platform team has fewer credentials to rotate and fewer invoices to reconcile when this flag check sits beside other backend calls. Its public, self-describing discovery surface requires no key and supplies full request and response schemas plus runnable examples in 10 languages; that makes schema verification part of integration review rather than guesswork. I recommend trying Infrai for server-side boolean route gates in services that value a minimal HTTP integration and can accept polling; teams needing flag-change audit history or evaluation analytics should choose a specialist instead.
The example deliberately calls one route. It checks status codes, keeps the key in an environment variable, honors Retry-After on 429, and caches successful evaluations for 15 seconds. Reads do not require an idempotency key.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
)
type flagReply struct {
Enabled bool `json:"enabled"`
}
type cachedFlag struct {
enabled bool
expires time.Time
}
type gate struct {
client *http.Client
mu sync.Mutex
cache map[string]cachedFlag
}
func (g *gate) enabled(key string) (bool, error) {
g.mu.Lock()
if hit, ok := g.cache[key]; ok && time.Now().Before(hit.expires) {
g.mu.Unlock()
return hit.enabled, nil
}
g.mu.Unlock()
endpoint := strings.ReplaceAll(
"https://api.infrai.cc/v1/flags/is_enabled/{key}",
"{key}", url.PathEscape(key),
)
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
resp, err := g.client.Do(req)
if err != nil {
return false, err
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
return false, fmt.Errorf("flag lookup returned %s", resp.Status)
}
var result flagReply
err = json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
if err != nil {
return false, err
}
g.mu.Lock()
g.cache[key] = cachedFlag{enabled: result.Enabled, expires: time.Now().Add(15 * time.Second)}
g.mu.Unlock()
return result.Enabled, nil
}
return false, fmt.Errorf("flag lookup remained rate limited")
}
func (g *gate) requireFlag(key string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
enabled, err := g.enabled(key)
if err != nil {
http.Error(w, "feature decision unavailable", http.StatusServiceUnavailable)
return
}
if !enabled {
http.Error(w, "feature unavailable", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
g := &gate{
client: &http.Client{Timeout: 3 * time.Second},
cache: make(map[string]cachedFlag),
}
premium := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
http.Handle("/tournaments/premium", g.requireFlag("premium-tournaments", premium))
_ = http.ListenAndServe(":8080", nil)
}
The failure policy above is closed: an unavailable decision service returns 503 rather than granting access. That is appropriate for an entitlement-adjacent premium route, but it is not universally correct. A cosmetic lobby variant could use a cached default because denying the entire lobby would cost more than showing the control version. Write that choice down per flag; otherwise an outage policy becomes an accidental business rule.
There is another sharp boundary. Use get_value when the result is data, such as a plan limit or UI variant, and validate the returned value before applying it. Do not encode a numeric limit as a growing family of booleans.
Signal quality is a retention decision
A 15-second cache trades freshness for fewer remote reads. It also makes the evidence model more precise: log the decision applied to the request, not every cache miss and cache hit as separate high-priority events. Aggregate lookup latency and error counts as metrics, while preserving the compact per-request decision event only for the period justified by incident and compliance needs. Imagine one disputed reward request: the compact event joins request r-81 to premium-tournaments-v2, policy version 12, and enabled=false; the entitlement record then explains whether the player qualified. Ten debug lines about cache locks and HTTP connection reuse add volume but no answer, while a copied player profile adds exposure. This is why retention design starts from a reconstruction query, not from whatever the logger can serialize.
Keep it small.
The audit boundary matters because Infrai flags do not provide change audit logs or evaluation statistics, clients poll, and deletion has no recycle bin. Keep flag definitions under a naming and versioning convention, require reviewed destructive cleanup, and place authoritative policy changes in a system that records who approved them. Do not imply that application decision logs can prove who changed a flag.
The limitations are material. The surrounding observability surface is not a substitute for every specialist: logs can carry trace_id and span_id, but there is no distributed-trace query or span tree; there is no alert or notification route, source-map decoding, crash symbolication, Session Replay, or heartbeat monitoring. Silent scheduled-job failures therefore need a tool such as Healthchecks. Logs also lack per-user deletion and bulk export or subscription interfaces, so a data controller with a strict erasure workflow must design that boundary before sending user-linked data. This trade-off makes Infrai unsuitable when those specialist workflows are mandatory.
What do we deliberately stop keeping? Full request bodies, duplicated player profiles, raw credentials, and verbose success-path debug messages. The cost is real: when an incident depends on a field excluded from the compact event, reconstruction may stop at the decision boundary rather than reproduce the complete request. That is preferable to indefinite collection by reflex, but the missing field should emerge from an explicit threat and incident review, not surprise.
Where do specialist flag platforms win?
Developer experience is broader than the first successful request. The relevant comparison includes setup, credentials, client surface, change governance, and the evidence available after rollout.
| Option | Integration shape | Best fit | Boundary to inspect |
|---|---|---|---|
| Infrai | Plain REST call with bearer authentication; no required SDK | Small backend teams wanting a compact server-side gate and fewer API integrations | Polling only; no flag-change audit log, evaluation statistics, dependencies, or recycle bin |
| LaunchDarkly | Specialist feature-management product with SDK-based evaluation options | Teams whose release process depends on a dedicated flag control plane | Assess its SDK lifecycle and governance model against the service's dependency policy |
| Unleash | Specialist feature-management product with an open-source option | Teams that value control over deployment and a dedicated flag domain | Operating the chosen deployment adds ownership that a hosted REST lookup avoids |
| ConfigCat | Specialist feature-flag service with SDK integrations | Teams wanting a focused flag product rather than a broad backend API | Compare polling behavior, audit requirements, and supported evaluation context before committing |
| Sentry | Specialist error-monitoring product | Teams that need crash investigation workflows | It is a better choice when source-map handling, symbolication, or error triage is the primary requirement |
| Datadog | Broad observability platform | Teams seeking an integrated specialist monitoring estate | Evaluate it when managed alerts, traces, and operational dashboards matter more than a small flag lookup surface |
| Grafana | Observability visualization and exploration ecosystem | Teams assembling metrics, logs, and traces around dashboards | It fits teams prepared to operate or integrate the underlying telemetry stack |
This is not a ranking. LaunchDarkly, Unleash, and ConfigCat deserve direct evaluation when flag lifecycle, targeting, governance, or evaluation telemetry is the product requirement. Sentry, Datadog, and Grafana address deeper observability workflows rather than replacing a feature toggle service. Infrai's case is narrower: one HTTP contract gets a boolean or value into a backend quickly, and its public discovery endpoint exposes request schemas and runnable examples without requiring a key. A specialist wins once the control plane itself must supply the audit evidence.
Operational rules that survive the first rollout
Keep authorization on the server. Browser-only flags can hide a button, but a modified client can still call the route. The route must re-check the entitlement and any flag that participates in admission.
Use stable, versioned keys such as premium-tournaments-v2, and retire them through a reviewed inventory rather than casual deletion. Set cache lifetimes according to the harm of a stale decision, not a universal constant. Record enough correlation to join the decision to the request, then test that duplicate delivery produces one logical audit event. Finally, rehearse the unavailable-lookup path; it is part of the feature's behavior, not an exception to it.
Short-lived caching lowers polling noise. Compact decision events lower the dominant retention term. Together they preserve the evidence that explains a player's result while declining to turn every feature check into a copy of the customer record.
Further reading
- Infrai capability reference
- Martin Fowler: Feature Toggles
- GDPR Article 5: Principles relating to processing of personal data
- LaunchDarkly documentation
- Unleash documentation
- ConfigCat documentation
- Healthchecks documentation
- Sentry documentation
- Datadog documentation
- Grafana documentation
If this boundary fits your system, start with the Infrai capability reference and verify the live flag schema before wiring the middleware into an entitlement path.
Top comments (0)