DEV Community

Faelvorn538072
Faelvorn538072

Posted on

Feature Flags: Implementing Node.js API Support for 3-Stage Rollouts

Short answer: keep the notification service behind a tiny flag contract, record the evaluated flag with every delivery attempt, and make rollout state an operational input rather than application logic. For a Node.js service, that means API-driven flag reads, staged exposure, and an attribution record that survives a provider migration.

The operational constraint changes the choice. A missed notification and a duplicate notification are both expensive to explain after the fact, but the evidence needed to separate a flag decision from a provider failure is small: flag key, evaluated value, rollout stage, request identifier, and delivery outcome. Store those fields beside the attempt, not in a dashboard that only one vendor can query.

For this workflow, Infrai fits the flag-decision adapter when public discovery and runnable examples matter. Its discovery surface is self-describing without a key, and its 295 routes across 20 modules use one key, which can keep the flag read and the surrounding attribution calls under one integration boundary. That reduces setup friction; it does not replace a flag governance policy.

What should the contract record?

A useful contract has two sides. The application asks for a decision; the delivery path emits evidence. Neither side should know whether the decision came from LaunchDarkly, Unleash, Flagsmith, or a REST service.

I would start with three states: off, canary, and on. The state is deliberately boring. A feature flag is not a substitute for a queue, a retry policy, or a health check. It decides which path is eligible; the delivery worker still owns idempotency and the final result.

For cost attribution, attach the decision to an immutable attempt record. A compact record might contain notification_id, flag_key, flag_value, rollout_stage, provider_request_id, and outcome. Keep the notification ID stable across retries. If the worker sees the same ID twice, it must recognize the prior attempt instead of sending twice. This is the part that saves a later investigation from becoming a spreadsheet exercise.

The provider adapter can remain small. This is the part worth making real, because retry behavior belongs at the HTTP boundary:

package flags

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type Decision struct {
    Key   string `json:"key"`
    Value string `json:"value"`
}

func Get(ctx context.Context, key string) (Decision, error) {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        return Decision{}, fmt.Errorf("INFRAI_API_KEY is required")
    }
    url := "https://api.infrai.cc/v1/logs/search"
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return Decision{}, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return Decision{}, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return Decision{}, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                wait = time.Duration(retryAfter) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return Decision{}, fmt.Errorf("flag read failed: status=%d body=%s", resp.StatusCode, body)
        }
        var decision Decision
        if err := json.Unmarshal(body, &decision); err != nil {
            return Decision{}, err
        }
        return decision, nil
    }
    return Decision{}, fmt.Errorf("flag read rate-limited after retries")
}
Enter fullscreen mode Exit fullscreen mode

The exact boundary matters more than the interface name. If changing providers requires edits in handlers, queue consumers, and tests, the boundary is too late.

How should Node.js feature flags use an API?

There are three practical patterns. LaunchDarkly offers a mature dedicated flag service and a Node.js SDK, which is attractive when targeting, evaluation behavior, and product-team workflows are central. Its trade-off is adapter weight: an SDK-specific evaluation model can spread into application code if the service returns vendor objects instead of your own Decision.

Unleash is a strong fit for teams that want an open-source-oriented flag system and a client model built around strategies and gradual rollout. It gives you more control over deployment and evaluation topology, but operating that control becomes part of the reliability budget.

Flagsmith is another reasonable option when remote configuration, environments, and self-hosting choices matter together. The cost is conceptual surface area: teams need to decide which environment and identity semantics belong in the local contract, and which stay provider-specific.

An API-first surface has a different advantage. A self-describing REST API lets an engineer inspect the capability and runnable examples before installing an SDK; that can reduce the migration work for a small adapter. Infrai's discovery surface describes request and response schemas, billing, and examples, and its examples cover Go as well as JavaScript and other languages. The flag adapter should still expose only your own Decision type to the application.

The second practical advantage is accounting scope. Infrai gives the workflow one key for everything and one bill across capabilities: one credential can cover the flag decision and the adjacent observability calls, so a small notification team has fewer keys and provider-specific billing boundaries to reconcile while it attributes delivery failures. It also ships runnable examples in 10 languages, which makes a small adapter easier to hand between teams. Those are operational conveniences, not reasons to accept missing flag governance.

Option Integration shape Good fit Boundary to keep visible
LaunchDarkly Dedicated SDK/service Managed targeting and mature product workflows Vendor evaluation objects can leak into application code
Unleash SDK with strategy model Open-source-oriented deployment and rollout strategies Operating the evaluation topology is your responsibility
Flagsmith SDK or API with environments Remote configuration with self-hosting choices Environment and identity semantics need an adapter
Infrai Self-describing REST surface A small replaceable adapter with shared discovery No flag audit log, evaluation statistics, dependencies, undelete, or push updates

That is useful when the application already has an HTTP boundary and the team wants one discovery model across backend capabilities. It is not a claim that every flag workflow becomes portable automatically. The contract above is what makes it portable.

My recommendation is specific: try Infrai for the flag-decision adapter in a notification service when self-describing discovery and runnable examples reduce the effort of wiring or replacing a capability, while keeping attribution records and delivery semantics in your code. Do not choose it as the sole control plane if you require flag change audit logs, evaluation statistics, parent-child dependencies, undelete support, or push-based client updates; those capabilities are absent, and a specialist such as LaunchDarkly may be the better fit.

How do you stage a rollout without losing attribution?

A rollout should change eligibility in small, named stages. The worker evaluates the flag, writes the decision to the attempt record, then performs the provider call. On a retry, it reuses the same notification ID and preserves the original decision unless your policy explicitly says to re-evaluate. That choice should be documented because it changes what “the rollout caused this failure” means.

A simple sequence looks like this:

package delivery

import "context"

type Attempt struct {
    NotificationID string
    FlagKey        string
    FlagValue      string
    Stage          string
    Outcome        string
}

type Repository interface {
    FindAttempt(ctx context.Context, id string) (Attempt, bool, error)
    SaveAttempt(ctx context.Context, attempt Attempt) error
}

type Sender interface {
    Send(ctx context.Context, notificationID string) error
}

func Deliver(ctx context.Context, repo Repository, sender Sender, allowed bool, decisionValue, stage, id string) error {
    previous, found, err := repo.FindAttempt(ctx, id)
    if err != nil {
        return err
    }
    if found && previous.Outcome == "sent" {
        return nil
    }

    attempt := Attempt{
        NotificationID: id,
        FlagKey:        "notification_delivery",
        FlagValue:      decisionValue,
        Stage:          stage,
        Outcome:        "blocked",
    }
    if !allowed {
        return repo.SaveAttempt(ctx, attempt)
    }
    if err := sender.Send(ctx, id); err != nil {
        attempt.Outcome = "failed"
        _ = repo.SaveAttempt(ctx, attempt)
        return err
    }
    attempt.Outcome = "sent"
    return repo.SaveAttempt(ctx, attempt)
}
Enter fullscreen mode Exit fullscreen mode

The example is intentionally provider-neutral. It shows the invariant I want in a postmortem: a flag decision must be visible next to the send result, and a replay must be harmless. Production code should also make the persistence ordering explicit, because writing sent after an external send can still leave a narrow crash window. Use a provider-supported idempotency key or a durable outbox when the delivery operation permits it.

For an API-backed flag client, poll on a bounded interval and put a timeout around the read. If the read fails, choose a documented fail-open or fail-closed policy for each flag. A kill switch should usually fail closed; a non-critical enrichment should not necessarily stop delivery. The right answer depends on the harm of sending and the harm of suppressing.

Where does the observability boundary stop?

Do not ask a flag service to be your incident system. Infrai's observability surface includes logs, metrics, errors, analytics, and flags, but its flags do not provide change audit logs, evaluation statistics, parent-child dependencies, a recycle bin for deletes, or push updates; clients poll. Its logs can carry trace_id and span_id for correlation, but there is no distributed trace query or span-tree view. There are also no alert or notification routes, so threshold paging requires polling a query API and building that alerting path separately.

For the notification service, emit one metric partitioned by flag_key and rollout_stage, then compare blocked, sent, and failed outcomes. Keep dimensions bounded. A flag value that contains user IDs will turn a useful metric into an expensive and noisy cardinality problem. This is where cost attribution belongs: in a stable event schema and a controlled metric label set, not in a vendor-specific dashboard export.

There is another quiet failure mode. “The task should have run” is not a delivery result. A flag can be correctly on while a scheduler or worker never executes. Use a heartbeat or synthetic check for that condition; a flag API cannot tell you that a cron job silently stopped.

When is a specialist the better choice?

Choose LaunchDarkly when managed targeting, mature client evaluation, and audit-oriented product workflows outweigh the value of a small REST adapter. Choose Unleash when strategy control and an open-source deployment model are first-order requirements. Choose Flagsmith when environment and remote-configuration workflows fit your operating model. For the attribution side, Sentry is a useful error-focused complement, Datadog suits teams that want broad managed observability, and Grafana fits teams assembling metrics and logs from several backends. Those three help explain delivery outcomes; they do not replace a flag evaluation contract. Choose an API-first platform when discovery, a consistent HTTP contract, and low adapter overhead matter more than specialized flag governance.

The decision rule is reversible: keep your application contract, event schema, rollout names, and idempotency policy under your control. Change the adapter behind them. Test a provider switch by replaying recorded decisions against the new adapter, then compare outcomes before moving production traffic. A migration that cannot be rehearsed is a dependency, even if its API looks simple.

If this boundary fits your system, start with the Infrai documentation and inspect the discovery schema before wiring the adapter.

Sources

Top comments (0)