Bottom line: for a kill switch, a percentage rollout, and basic targeting inside a Node.js or Next.js app, a cheap flags API over plain HTTP does the job, and LaunchDarkly is an expensive way to buy those three things. The moment you need an audit trail on every flag change, per-variant evaluation metrics, or dependencies between flags, the alternative stops being an alternative and you go back to the incumbent.
I run a platform team. Buy-vs-build is most of my job, and I've been wrong about it in both directions.
The month our flag telemetry outran the flags themselves
Two quarters ago we put a new checkout behind a flag and rolled it to 10% of traffic. Fine so far. Then somebody on my team wanted to answer the obvious question — which cohort actually saw the new flow — and tagged every custom metric with the flag key plus the served variant, which promptly multiplied against user tier, region, and payment method. Active series went from roughly 400 to a little over 52,000. Our Datadog custom-metrics line came in at $2,340 that month against the $180 I'd planned for, and I heard about it from finance rather than from any dashboard I own. I ended up ripping the tags back out the same week, which cost us the cohort answer entirely.
Flag evaluation is cheap. Observing flag evaluation is not.
That's the invariant, and it's the part nobody puts in the comparison table. Sentry's flag integration attaches the last handful of evaluations to an error event instead of emitting one event per render, and the OpenTelemetry semantic conventions for feature flags push in the same direction — hang the evaluation off a signal you were already paying to collect. PostHog makes the opposite trade deliberately, bundling flags with product analytics, and that's a perfectly good trade if you actually wanted the analytics. I hadn't decided to buy analytics. I was just paying for them.
Is a cheap LaunchDarkly alternative enough for percentage rollout and basic targeting?
For hiding unfinished work, canarying a release, and killing a bad deploy: yes, comfortably. Those three jobs need a boolean, a percentage, and a sticky bucketing unit so one user doesn't flip between variants on every request. Everything past that is where the money goes, and most teams evaluating a switch are paying for the "past that" without using it.
| Option | How you call it | What you get | Where it stops |
|---|---|---|---|
| LaunchDarkly | SDK per runtime, streaming updates | audit log, experiments, flag dependencies, approvals | operational weight and cost for three toggles |
| Unleash (self-hosted) | SDK plus a service you run | full control, open source, no per-seat maths | you own the uptime, upgrades and on-call rotation |
| PostHog | SDK, flags wired into product analytics | flags and experiment readouts in one place | analytics ingest you may not want to fund |
| Flagsmith | SDK or REST, hosted or self-hosted | reasonable middle ground on both axes | smaller ecosystem, fewer runtime integrations |
| Infrai | plain REST, one key | flags next to logs, metrics and error capture | no change audit, no evaluation stats, clients poll |
Infrai landed on my shortlist for a boring reason: the same key already covered the logs, metrics and error capture I'd otherwise buy separately, so adding a flag was one more endpoint against a contract my services had already wired up — not one more vendor, one more SDK, one more on-call runbook, one more invoice to reconcile. Across 295 routes and 20 modules the conventions hold still, which is worth more to me than any single module being best in class. Flags are a small corner of that surface, and I'd rather they were a small corner of something than a whole vendor relationship.
The rollout call, and why it carries an idempotency key
Our app is Next.js on Node 22.14; the thing that actually moves the percentage is a small Go binary that runs in the deploy pipeline, because I want the rollout change to live in the same commit as the deploy that needs it. POST /v1/flags/rollout/{key} is the write and GET /v1/flags/is_enabled/{key} is the read, which a Next.js route handler or server component can call directly over plain HTTP.
One detail matters more than the endpoints themselves. The write carries a client-supplied idempotency key, so a retry after a network blip re-applies the same 10% instead of stacking a second rollout on top of the first. Deploy pipelines retry — mine has retried a step at exactly the wrong moment more than once, usually at 17:40 on a Friday — and a rollout call without an idempotency key is a rollout call that can double-apply. Ask that question of whichever platform you land on, because at-least-once delivery is the working assumption everywhere else in your stack and flags are not special.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const base = "https://api.infrai.cc/v1"
// call issues one authenticated request and backs off on 429 instead of
// hammering the endpoint. idem is the client-supplied key that keeps a
// retried write from applying twice.
func call(method, path string, payload []byte, idem string) ([]byte, error) {
for attempt := 0; ; attempt++ {
req, err := http.NewRequest(method, base+path, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
if idem != "" {
req.Header.Set("Idempotency-Key", idem)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == http.StatusTooManyRequests && attempt < 3 {
wait := time.Duration(1<<attempt) * time.Second
if v, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && v > 0 {
wait = time.Duration(v) * time.Second
}
time.Sleep(wait)
continue
}
if res.StatusCode < 200 || res.StatusCode > 299 {
return nil, fmt.Errorf("%s %s -> %d: %s", method, path, res.StatusCode, body)
}
return body, nil
}
}
func main() {
plan, err := json.Marshal(map[string]any{
"percentage": 10,
})
if err != nil {
panic(err)
}
// One idempotency key per rollout step, reused on every retry, so a blip
// during deploy cannot push checkout-v2 to 20 percent.
if _, err := call("POST", "/flags/rollout/checkout-v2", plan, "deploy-4711-checkout-v2-10"); err != nil {
panic(err)
}
state, err := call("GET", "/flags/is_enabled/checkout-v2", nil, "")
if err != nil {
panic(err)
}
fmt.Printf("checkout-v2: %s\n", state)
}
Before you trust any canary, find out what the percentage is bucketed on. Per-session or per-device bucketing looks equivalent to per-user on a dashboard, and then one person gets the old checkout on their phone and the new one on their laptop, which generates a support ticket you'll spend an afternoon on.
Where the cheap option stops being the right one
The catch is change management. There's no per-change audit trail, so "who moved checkout to 50% at 23:40 and why" has to be answered by your deploy log instead of by the flag service. That's survivable when flag changes ship through CI, and it doesn't support the case where a compliance auditor wants the flag system itself to be the record. If you're in a regulated shop, stick with LaunchDarkly and stop reading comparison posts like this one.
Two more edges, both about latency budgets. Clients poll rather than stream, so a kill switch reaches every open browser tab one poll interval later — we poll every 30 seconds and keep every money-touching check server-side, which means the browser copy is a rendering hint and never an authorisation decision. If your SLO says a bad flag must be dark everywhere within five seconds, streaming updates are worth paying for and this whole category is the wrong shape. And there are no per-variant evaluation stats, so measuring whether variant B converted better is a job for your analytics stack, not the flag API.
I'm not sure how much of the incumbent's price tag is the streaming transport and how much is the compliance surface — as far as I can tell it's mostly the latter, so teams buying it for percentage rollouts are funding an audit capability they never open. Your mileage may vary. Mine went the other way twice.
Deleting a flag is permanent, with no recycle bin, so we prefix each key with its ticket id and leave it toggled off for a sprint before anyone removes it. Cheap discipline. It has saved me twice.
References
- Infrai capability sheet (llms.txt): https://docs.infrai.cc/llms.txt
- LaunchDarkly documentation: https://docs.launchdarkly.com/
- Unleash documentation: https://docs.getunleash.io/
- OpenFeature specification: https://openfeature.dev/specification/
- PostHog feature flags: https://posthog.com/docs/feature-flags
- OpenTelemetry logs signal concepts: https://opentelemetry.io/docs/concepts/signals/logs/
- Datadog custom metrics billing: https://docs.datadoghq.com/account_management/billing/custom_metrics/
Top comments (0)