Percentage rollouts and user targeting in Express with a REST flag API
Infrai evaluates rules and the rollout bucket server-side, or hands you the definitions to resolve in-process. A Node 22 Express example, with the split measured.
Infrai’s flags API answers “is this on for this user” in two ways, and picking between them is the first design decision. GET /v1/flags/get_value/{key} takes an evaluation context and returns the decided value — targeting rules first, then the percentage bucket. Or you fetch the definitions once with GET /v1/flags/list, cache them in your Express process, and resolve locally on every request.
This walkthrough builds the second, because that’s where the interesting decisions live, and uses the first as the cross-check. The reason to do the work in-process is blunt: a flag check becomes a property lookup on a cached object, so it adds no latency and can’t fail when the network does. The cost is that the bucketing arithmetic now lives in your codebase, and every service reading that flag has to hash identically or users will flip between variants as they move across your backend.
Storing the rollout
Four fields, all required. percentage is an integer in [0,100]. salt is any string, and changing it reshuffles everyone. sticky_unit picks the identifier to bucket on — user_id, session_id, device_id or tenant_id, that last one being what you want when a feature belongs to a workspace rather than a person. version is optimistic concurrency: send the version you read, and a concurrent ramp by someone else comes back as FLAG_VERSION_CONFLICT instead of quietly overwriting them.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/flags/rollout/kb_new_search_ranker" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"percentage": 25, "salt": "ranker-2026-07", "sticky_unit": "user_id", "version": 2}'
{
"ok": true,
"data": {
"key": "kb_new_search_ranker",
"type": "bool",
"default_value": true,
"enabled": true,
"version": 3,
"rules": [{ "if": { "plan": "enterprise" }, "then": true }],
"rollout": { "percentage": 25, "salt": "ranker-2026-07", "sticky_unit": "user_id" },
"tags": { "owner": "kb-guides" }
}
}
Ramping is the same call with a bigger number and the version you just got back — 25, then 50, then 100. Because the salt is unchanged, everyone already in the bucket stays in it: users only ever join the treatment group as you ramp, never churn between arms. Change the salt and you throw that away, so treat it as fixed for the life of the rollout.
Asking the server for a decision
The rules array in that response is evaluated, not just stored. A rule is {"if": …, "then": …}, the predicate is matched against the context you pass, and the first match wins.
curl -sS -G -X GET "https://api.infrai.cc/v1/flags/get_value/kb_new_search_ranker" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
--data-urlencode "user_id=u_8812" \
--data-urlencode 'context={"plan":"enterprise"}'
{
"ok": true,
"data": {
"value": true,
"variant": null
}
}
That’s the whole API for teams who don’t mind a call per evaluation, and GET /v1/flags/get_all does the same for every flag under one context if you’d rather resolve a page’s worth in one request. POST /v1/flags/set is strict about what it stores, too — an unknown top-level field is a 400 rather than an accepted typo, so a misspelled defualt_value fails loudly instead of creating a flag that reads wrong forever.
The middleware
One poll loop, one cached snapshot, one synchronous resolver per request.
import express from "express";
import { createHash } from "node:crypto";
import process from "node:process";
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
let snapshot = { defs: {}, at: 0 };
async function get(path) {
const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${KEY}` } });
const body = await res.json();
if (!body.ok) throw new Error(`${path}: ${body.error?.code} ${body.error?.message}`);
return body.data;
}
async function refresh() {
const page = await get("/v1/flags/list?limit=200");
const defs = {};
for (const item of page.items) defs[item.key] = item;
snapshot = { defs, at: Date.now() };
}
function inBucket(salt, unit, percentage) {
if (percentage <= 0) return false;
if (percentage >= 100) return true;
const digest = createHash("sha1").update(`${salt}:${unit}`).digest();
return digest.readUInt32BE(0) % 100 < percentage;
}
function resolve(key, subject, fallback = false) {
const def = snapshot.defs[key];
if (!def) return fallback;
// 1. A disabled flag is off for everyone — this is the kill switch.
if (def.enabled === false) return fallback;
// 2. Targeting rules, in the same order the server applies them.
for (const rule of def.rules ?? []) {
if (rule.if && typeof rule.if === "object") {
const matches = Object.entries(rule.if).every(([k, v]) => subject[k] === v);
if (matches) return rule.then;
}
}
// 3. Then the percentage bucket.
const rollout = def.rollout;
if (rollout) {
const unit = subject[rollout.sticky_unit];
if (!unit) return fallback;
return inBucket(rollout.salt, String(unit), rollout.percentage)
? def.default_value
: fallback;
}
return def.default_value ?? fallback;
}
await refresh();
setInterval(() => refresh().catch((e) => console.error("flag refresh:", e.message)), 30_000).unref();
const app = express();
app.use((req, _res, next) => {
const subject = {
user_id: req.header("x-user-id") ?? "anonymous",
plan: req.header("x-plan") ?? "free",
};
req.flag = (key, fallback = false) => resolve(key, subject, fallback);
next();
});
app.get("/search", (req, res) => {
const useNewRanker = req.flag("kb_new_search_ranker");
res.json({ ranker: useNewRanker ? "v2" : "v1" });
});
app.listen(3000, () => console.log("listening on :3000"));
The ordering in resolve is the part to copy deliberately, and it mirrors what the server does: disabled beats everything, rules beat the bucket, the bucket governs whoever the rules didn’t catch. Get rules and percentage the wrong way round and a targeted enterprise account lands in the control arm and files a ticket.
The kill switch, and what caching costs you
POST /v1/flags/toggle/{key} with {"enabled": false, "version": 3} turns a flag off for every context at once, and get_value returns the off value on the next request. That’s the incident lever — no deploy, no code path.
curl -sS -X POST "https://api.infrai.cc/v1/flags/toggle/kb_new_search_ranker" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"enabled": false, "version": 3}'
The catch is that a cached snapshot only sees it at the next poll. Thirty seconds is a fine default for a ramp; for a flag you’d actually reach for during an incident, either drop the interval to five seconds or call get_value directly on that one path and accept the round trip. Pick per flag, not per codebase.
Does it actually split 25/75?
Worth checking rather than assuming, since a skewed hash quietly ruins the comparison you’re running.
import { createHash } from "node:crypto";
function bucket(salt, unit) {
return createHash("sha1").update(`${salt}:${unit}`).digest().readUInt32BE(0) % 100;
}
const SALT = "ranker-2026-07";
const TARGET = 25;
for (const n of [1000, 10_000, 100_000]) {
let hits = 0;
for (let i = 0; i < n; i++) if (bucket(SALT, `u_${i}`) < TARGET) hits++;
console.log(`n=${n}: ${hits} in bucket (${((hits / n) * 100).toFixed(2)}%)`);
}
console.log("u_8812 =>", bucket(SALT, "u_8812"));
Running that gives 24.90% at 1,000 ids, 25.10% at 10,000 and 24.89% at 100,000 — close enough that sampling error dominates well before the hash does. One caveat on the modulo: 2^32 isn’t divisible by 100, so buckets 0–95 are very slightly favoured. At a 4×10⁻⁸ relative bias it’s irrelevant next to your traffic variance, but it’s there.
Confirm the stored configuration matches what your code thinks it’s reading:
curl -sS "https://api.infrai.cc/v1/flags/get/kb_new_search_ranker" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '{key: .data.key, version: .data.version, rollout: .data.rollout, rules: .data.rules}'
Where this sits against the alternatives
| This approach | LaunchDarkly | Unleash | PostHog | |
|---|---|---|---|---|
| Where evaluation runs | server or your process | vendor SDK | vendor SDK | vendor SDK |
| Client SDK | none — plain REST | every language | many languages | many languages |
| Rule authoring UI | API and console | rich | good | good |
| Change propagation | poll interval | streaming | streaming | poll or streaming |
| Experiment statistics | not included | included | no | included |
There’s no client SDK here, which is the limitation to weigh honestly: in-process resolution means you own the resolver and its consistency across services, and there’s nothing to drop into a browser bundle. LaunchDarkly is the better buy when product managers need to compose targeting rules in a UI and see streaming propagation without a deploy — that’s the whole product, and rebuilding it is not a weekend. PostHog wins when the rollout and the funnel it’s meant to move should live in one tool.
What you get here instead is that the flag store, the metrics, the queue and the error tracker answer to one key and one invoice, and every flags route is free and rate-limited without touching the $2 credit a new account starts with (verified 2026-07-26). Rates move, usually downward, so read the live number:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.namespace=="flags") | {id, free: .billing.free}]'