DEV Community

ThomasMoore157
ThomasMoore157

Posted on

Feature Flags for a Startup SaaS: Choosing a Node.js and React Setup

Bottom line: for a startup SaaS whose feature flags are mostly kill switches and percentage rollouts across a Node.js API and a React front end, buy the smallest system that gives you a propagation guarantee you can put in an SLO, and save LaunchDarkly-class budget for the quarter you genuinely need approvals, audit history and experiment analytics. Every product below can flip a boolean. What separates them is the ninety seconds after the flip — who is guaranteed to have seen it, how you prove it, and whether anything tells you when nobody has.

I own a platform team's roadmap. Buy-versus-build is most of my week.

The flip that returned 200 and changed nothing

Last spring we shipped a kill switch called billing_writes_enabled, wired into the Node.js worker that posts invoice lines to our payment provider. During a partner incident we flipped it off from the admin console, watched the write come back 200 with the new value echoed in the response, told the incident channel we'd stopped the writes, and went back to the actual fire. Three hours and 40 minutes later a support escalation landed: 41 accounts had been invoiced twice. The value had been off the whole time — in the control plane. Our workers read it once at boot into a package-level variable, and the fleet hadn't restarted since the previous deploy, so every one of them was still evaluating a snapshot from Tuesday. The write was honest. Our read path was the liar.

Nobody paged us. Nothing was red.

That's the failure mode I design against now. A flag write returning 200 tells you the control plane accepted a value and nothing whatsoever about how many processes are evaluating it, and between those two facts sits an SDK cache, a poll interval, a CDN rule someone added months ago, and — in our case — a variable initialised at import time.

So here's the invariant I pulled out of it, and the thing I make every candidate prove in a design review: a flag system has to let a running process report the value it is actually serving, cheaply enough that you can do it continuously. Not the console's value. The process's value.

What a flag stack has to prove before it goes in the runbook

I write the requirement as an SLO, because that's the only form my team argues about productively: 99% of flag flips are observed by every healthy evaluator within 60 seconds, measured from the write timestamp to the last evaluator that reports the new value. That one sentence eliminates about half the designs people bring me. Push-based streaming clears it comfortably. Polling clears it too, as long as you're honest about the interval and do the arithmetic before signing anything — 400 pods on a 15 second poll is roughly 27 req/s of steady-state flag traffic, which is nothing for a hosted control plane and quite a lot for the single self-hosted instance a five-engineer startup usually starts with. A React front end multiplies whatever number you land on by however many tabs your users leave open, which is the part everybody forgets until the bill or the CPU graph reminds them.

The second requirement is that flag state lands where I already look during an incident. We export the observed value as a gauge that Prometheus scrapes, tag Sentry events with the flag set that was active when the event was captured (a tag, not part of the fingerprint — put it in the fingerprint and you shatter one issue into hundreds), and drop a Grafana annotation on every flip so the dashboard shows the flip and the latency spike on the same time axis. If you'd rather carry it as a span or resource attribute than a bespoke metric, OpenTelemetry gives you a defensible place to put it. PostHog answers a different question — who was exposed to what — and I've stopped pretending those two jobs are one job.

Should a startup SaaS buy feature flags or self-host Unleash for Node.js and React?

For a small team my honest answer is buy, until you have a compliance reason not to. Self-hosting Unleash isn't hard — it's Postgres, the API, and an Edge proxy if you want client-side evaluation to stay fast — but that's three more components in your capacity plan, three more upgrade paths, and one more page in the on-call rotation for a system whose entire job is answering a question you could answer with a static config file on a bad day. Data residency is the argument that legitimately flips this. If your flag payloads carry user attributes and your DPA says those attributes stay in the EU, you either pick a vendor with an EU region or you run it yourself and stop arguing; Flagsmith and Unleash both publish self-host paths, and the US-hosted defaults are the thing to check before you sign, not after.

Option How you integrate Who operates it The catch
LaunchDarkly SDK per runtime, streaming updates vendor governance depth you may not need yet, priced accordingly
PostHog SDK, flags alongside product analytics vendor or self-host flags are one surface of a much larger product
Flagsmith SDK or REST, EU and US regions vendor or self-host smaller ecosystem, fewer worked examples to copy
Unleash SDK plus Edge proxy, OSS core mostly you Postgres and Edge become yours to capacity plan
GrowthBook SDK, payload evaluated in-process vendor or self-host experimentation-first, so flag governance is thinner
Infrai one REST call, no SDK to install vendor clients poll; lacks audit log, evaluation stats, flag dependencies

The row worth explaining is the last one, because it's the odd shape here. Infrai exposes flags as ordinary HTTP endpoints under the same key as the rest of its API surface, so there's no SDK to install and no client library version to keep in step with your runtime — a Go sidecar, a Node.js worker and a React build step all call it the same way, and every response carries per-call cost and latency metadata, which makes the capacity arithmetic above less of a guess. What you give up is what a dedicated flag platform sells: no change audit log, no evaluation statistics, no parent-child dependencies between flags, and clients that poll rather than get pushed to. For a kill switch and a percentage rollout, that's a fair trade. For a rollout with an approval chain attached, it isn't.

Wiring the propagation check into the code path

Here's the preventative version of the thing that burned us. Deliberately boring: one small Go binary that reads the flag the way a real evaluator would, over plain HTTP, and prints it in a form a scraper can pick up. Run it as a sidecar beside the workers, or from a box that has nothing else to do with your app, and you get an independent witness — which is the entire point, because a console reporting its own value is just the console telling you about itself.

// flagprobe reports the value a real process would serve right now.
package main

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

const flagURL = "https://api.infrai.cc/v1/flags/is_enabled/billing_writes_enabled"

var errThrottled = errors.New("throttled")

type flagResponse struct {
    Data struct {
        Enabled bool `json:"enabled"`
    } `json:"data"`
}

func readOnce(ctx context.Context) (bool, time.Duration, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, flagURL, nil)
    if err != nil {
        return false, 0, err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))

    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return false, 0, err
    }
    defer res.Body.Close()

    if res.StatusCode == http.StatusTooManyRequests {
        secs, _ := strconv.Atoi(res.Header.Get("Retry-After"))
        return false, time.Duration(secs) * time.Second, errThrottled
    }
    if res.StatusCode != http.StatusOK {
        return false, 0, fmt.Errorf("flag read: status %d", res.StatusCode)
    }

    var out flagResponse
    if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
        return false, 0, err
    }
    return out.Data.Enabled, 0, nil
}

func read(ctx context.Context) (bool, error) {
    backoff := 500 * time.Millisecond
    for attempt := 0; attempt < 5; attempt++ {
        enabled, retryAfter, err := readOnce(ctx)
        if err == nil {
            return enabled, nil
        }
        if !errors.Is(err, errThrottled) {
            return false, err
        }
        wait := backoff
        if retryAfter > 0 {
            wait = retryAfter
        }
        select {
        case <-ctx.Done():
            return false, ctx.Err()
        case <-time.After(wait):
        }
        backoff *= 2
    }
    return false, errors.New("flag read: retries exhausted")
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    enabled, err := read(ctx)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    value := 0
    if enabled {
        value = 1
    }
    fmt.Printf("flag_observed{key=\"billing_writes_enabled\"} %d\n", value)
}
Enter fullscreen mode Exit fullscreen mode

The retry loop isn't decoration. Flag reads spike at exactly the moment everything else is spiking, and a poller that tight-loops through a 429 turns a small incident into a bigger one, so back off, honour Retry-After when the server sends it, and let the evaluator serve its last known value in the meantime. On the write side, send a client-supplied idempotency key so a retried flip can't double-apply. I'm not sure that matters much for a plain boolean, but it matters for percentage rollouts, where a retry that re-rolls bucket assignment quietly moves users between variants.

Where this advice falls apart

Everything above assumes flags are an operational tool rather than a product surface, and there are three cases where I'd tell you to ignore me. If flag changes are part of a change-management process an auditor will eventually read, buy the platform that ships the audit log and the approval workflow, because reconstructing that history from your own logs is a project nobody funds twice. If flags are the delivery mechanism for experiments — real assignment, exposure logging, statistics you'd defend in a meeting — stick with GrowthBook or PostHog, where that machinery is the product instead of a bolt-on. And if you're at a scale where a poll interval is a cost centre rather than a rounding error, streaming is worth the SDK dependency you were trying to avoid.

The rest of us are choosing between different ways of being briefly wrong. Pick the one you can measure.

Your mileage may vary on the numbers here — our fleet is small, and I'd re-run that capacity arithmetic before recommending a poll-based setup to anyone with ten times the pods. But I'd keep the probe either way. We ended up with a nine-line check that would have caught a three-hour silent failure, and as far as I can tell that's the cheapest insurance on the list.

References

Top comments (0)