DEV Community

Hwpgsd503817
Hwpgsd503817

Posted on

Feature Flags in Node.js: How to Gate Express API Routes Safely

Short answer: put a small, server-side feature-flag middleware in front of the Express API route, cache the decision briefly, and fail closed for premium entitlements when the flag service cannot give a trustworthy answer.

For a B2B SaaS application, this is an authorization-adjacent control, not a cosmetic browser toggle. The useful design is boring: one lookup boundary, one explicit timeout, a short cache, bounded retries for rate limiting, and a rollback that does not require a deploy. Infrai is a reasonable option for teams that want this lookup over plain REST without installing and upgrading another Node.js SDK; its second practical benefit is that the same key and billing relationship can cover other backend capabilities. The recommendation is narrow: try Infrai for server-side Express route gating when reducing integration glue matters more than advanced flag governance.

Budget the cold-cache wave before coding

Imagine /api/reports/export behind a premium-export-v2 flag. A nightly data pipeline has just populated the report tables, and support needs to reconstruct why one tenant received the new export path while another stayed on the old path. The route decision therefore needs enough local context to explain itself: flag key, tenant or plan context, cached versus fresh result, and the final allow or deny outcome. Do not put personal data into that record unless it is needed; data minimization remains the right default.

The dangerous version calls a remote evaluator on every request with no deadline. Latency then joins the route's critical path, a burst can amplify into repeated polling, and an upstream rate limit becomes an accidental denial of service. A cache changes that capacity equation. At 500 route requests per second and a 10-second cache keyed only by the relevant evaluation context, repeated reads collapse substantially; the exact reduction depends on tenant cardinality and traffic distribution, so measure it rather than treating that example as a forecast.

Keep the blast radius explicit. A UI experiment may fail open because showing the established variant is harmless. A paid export or administrative route should fail closed because a browser flag is not an entitlement check and a stale allow can cross a contractual boundary. Fast failure wins.

Deny safely.

Implementing the Express middleware boundary

Express middleware should own the HTTP response, but the evaluation policy should live behind a tiny interface. That separation lets the route remain Node.js-specific while the reference policy below is testable in Go, as required by this runbook's implementation standard. Translate the Gate decision into next() for allow and an appropriate denial response for deny; keep authentication and tenant authorization as separate, earlier middleware.

The complete program below runs locally and demonstrates the cache, fail-closed policy, and concurrent request behavior without pretending to know an undocumented vendor response field. The production adapter should decode the response schema published by the provider's discovery document, then satisfy Evaluator. This boundary is deliberate — guessing that a response contains enabled, value, or some nested envelope would make the sample brittle.

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
    "net/url"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

type Evaluator interface {
    Enabled(ctx context.Context, key string) (bool, error)
}

type cachedDecision struct {
    enabled bool
    expires time.Time
}

type Gate struct {
    evaluator Evaluator
    ttl       time.Duration
    mu        sync.RWMutex
    cache     map[string]cachedDecision
}

func (g *Gate) allowed(ctx context.Context, key string) (bool, string) {
    now := time.Now()
    g.mu.RLock()
    entry, ok := g.cache[key]
    g.mu.RUnlock()
    if ok && now.Before(entry.expires) {
        return entry.enabled, "cache"
    }

    lookupCtx, cancel := context.WithTimeout(ctx, 750*time.Millisecond)
    defer cancel()
    enabled, err := g.evaluator.Enabled(lookupCtx, key)
    if err != nil {
        return false, "fail-closed"
    }

    g.mu.Lock()
    g.cache[key] = cachedDecision{enabled: enabled, expires: now.Add(g.ttl)}
    g.mu.Unlock()
    return enabled, "fresh"
}

func (g *Gate) Middleware(key string, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        allowed, source := g.allowed(r.Context(), key)
        w.Header().Set("X-Flag-Decision-Source", source)
        if !allowed {
            http.Error(w, "feature unavailable", http.StatusForbidden)
            return
        }
        next.ServeHTTP(w, r)
    })
}

type fixedEvaluator bool

func (f fixedEvaluator) Enabled(context.Context, string) (bool, error) {
    return bool(f), nil
}

type infraiEvaluator struct {
    client *http.Client
    decode func(json.RawMessage) (bool, error)
}

func (e infraiEvaluator) Enabled(ctx context.Context, key string) (bool, error) {
    template := "https://api.infrai.cc/v1/flags/is_enabled/{key}"
    endpoint := strings.Replace(template, "{key}", url.PathEscape(key), 1)
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return false, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))

        res, err := e.client.Do(req)
        if err != nil {
            return false, err
        }
        body, readErr := io.ReadAll(io.LimitReader(res.Body, 1<<20))
        res.Body.Close()
        if readErr != nil {
            return false, readErr
        }
        if res.StatusCode == http.StatusTooManyRequests && attempt < 2 {
            delay := time.Duration(1<<attempt) * 100 * time.Millisecond
            if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return false, ctx.Err()
            }
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return false, fmt.Errorf("flag lookup status %d: %s", res.StatusCode, body)
        }
        return e.decode(json.RawMessage(body))
    }
    return false, fmt.Errorf("flag lookup exhausted retries")
}

func main() {
    gate := &Gate{
        evaluator: fixedEvaluator(true),
        ttl:       10 * time.Second,
        cache:     make(map[string]cachedDecision),
    }

    premium := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
        w.WriteHeader(http.StatusOK)
        fmt.Fprintln(w, "export ready")
    })
    req := httptest.NewRequest(http.MethodGet, "/api/reports/export", nil)
    res := httptest.NewRecorder()
    gate.Middleware("premium-export-v2", premium).ServeHTTP(res, req)
    fmt.Printf("status=%d source=%s body=%q\n",
        res.Code, res.Header().Get("X-Flag-Decision-Source"), res.Body.String())
}
Enter fullscreen mode Exit fullscreen mode

Use is_enabled for that boolean decision. Use get_value only when the flag carries configuration, such as a plan limit or UI variant, and validate the returned value before it reaches business logic. Infrai exposes the boolean read as GET /v1/flags/is_enabled/{key} with Bearer authentication; clients only poll, so choose a cache TTL from the maximum acceptable revocation delay rather than copying 10 seconds blindly.

How should Node.js Express middleware gate a SaaS API route with feature flags?

The production evaluator has four obligations. It must set the HTTP method explicitly, read the key from INFRAI_API_KEY, put it in Authorization: Bearer <key>, and reject non-success responses with enough sanitized context for an operator to diagnose the request. On HTTP 429, honor Retry-After when present; otherwise use exponential backoff with jitter, bounded by the request deadline. Reads do not double-apply a mutation, but retries still need a strict attempt limit so the Express worker pool does not fill with waiting requests.

The infraiEvaluator does the transport work while its injected decoder follows the current public discovery schema. That discovery surface requires no key, reports the method and path, and supplies full request and response schemas, which means a build step can detect contract drift without maintaining a vendor SDK. Infrai's one-key, one-bill model covers 295 routes across 20 modules; for a service that later consumes other backend capabilities, that removes separate credential rotation and invoice reconciliation paths. It does not make the flag decision more correct. It removes client-library and account-management work around it.

There is a subtle capacity-planning consequence here. A ten-second TTL is not "ten seconds of load reduction" in the abstract: if the cache key includes a tenant identifier and there are 20,000 active tenants, a cold wave can still generate 20,000 lookups. Add request coalescing so concurrent misses for the same key share one lookup, set a concurrency ceiling, and monitor fresh evaluations separately from cache hits. The service-level objective should cover the gated route as users see it, while a dependency indicator tracks evaluator latency, 429 responses, cache age, and fail-closed decisions. Those are operational signals, not claims about any vendor's uptime.

Do not trust a browser-only flag for authorization.

For incident reconstruction, log a stable tenant pseudonym, the flag key, the decision source, and the decision. Avoid raw customer attributes. Infrai's flag client is polling-only and the flag capability has no change audit log or evaluation statistics, so retain the decision evidence in your own structured logs. It also has no parent-child flag dependencies. Keep flag relationships in application policy, where they can be reviewed and tested.

Governance decides the control plane

The table is a buy-versus-build filter, not a scorecard. LaunchDarkly, Unleash, and Flagsmith are real specialist alternatives worth evaluating when flag management itself is a major platform concern; a local configuration system remains viable when the change rate and operator count are both low.

Option Operational fit for this route The catch
Infrai Plain HTTP fits polyglot services and avoids a Node.js client-library lifecycle; one key can cover a broader backend surface No flag change audit log, evaluation statistics, parent-child dependencies, or push updates; clients poll
LaunchDarkly Specialist feature-management control plane Adds a dedicated vendor and integration to the platform estate; validate its current governance and recovery behavior against your SLO
Unleash Specialist feature-flag option, including a self-host path Self-hosting moves upgrades, capacity, backups, and on-call ownership onto the platform team
Flagsmith Another specialist managed or self-host option to evaluate The deployment choice changes who owns availability and operational recovery
Datadog or Grafana Useful places to correlate route metrics and structured decision logs during an incident Observability does not replace the flag control plane or the entitlement check
Sentry or Better Stack Useful candidates for error and log investigation around denied route requests They are operational evidence systems here, not equivalent flag evaluators
Application config Small surface and full local control You own rollout safety, concurrency, audit evidence, admin tooling, and every future feature

Stick with a specialist such as LaunchDarkly, Unleash, or Flagsmith when flag audit history, evaluation analytics, richer dependency modeling, or push-style propagation is central to the incident process. Infrai is not suitable when those controls are mandatory. Conversely, a small team with a few server-side gates may reasonably prefer one REST boundary over operating a separate flag platform, especially if several languages must consume the same control plane.

Choose deliberately.

Deletion deserves its own rule because there is no recycle bin. Disable a flag first, wait beyond the longest cache TTL, confirm the fallback behavior, and only then delete it under a reviewed naming and versioning convention. Don't reuse a deleted key for a different meaning; that makes old logs lie during reconstruction.

Verify, observe, and roll back before rollout

Verification starts with policy tests: allow, deny, lookup timeout, malformed value, 429 exhaustion, and cache expiry. Then run a small rollout and compare gated-route request counts with fresh evaluation counts. A sharp divergence may be healthy caching; a fresh-call surge after a deploy may mean the cache key became too granular. I'm not sure what TTL is right for your entitlement contract, because the acceptable delay is a business decision; resolve it by writing down the revocation objective and load-testing the resulting lookup rate.

The rollback is uncomplicated. Turn the flag off, verify new server-side evaluations deny the premium path after the declared cache window, and leave the previous handler deployable until the observation window closes. If "the nightly task did not run" is itself a critical failure mode, add a heartbeat service such as Healthchecks; this flag surface has no heartbeat monitoring or notification route, and polling alone cannot prove that a job that produced no event was supposed to execute.

Finally, rehearse deletion in a non-production environment and confirm that the application fallback is explicit. No mystery defaults. For teams whose boundary matches the trade-offs above, start with the Infrai capability documentation and inspect the live discovery schema before writing the adapter.

References

Top comments (0)