Every team eventually needs feature flags. Roll out to 10% of users first.
Toggle something off without a redeploy. Show a feature only to beta testers.
Your options today: LaunchDarkly ($500+/month), Unleash (Docker, PostgreSQL,
maintenance), Flagsmith (same story), or hardcoded if-statements. None of
these work for a small team shipping fast.
So I built featurekit — the
dotenv of feature flags. Define flags in a JSON file, get typed evaluation
anywhere in your TypeScript codebase. Zero infrastructure, zero dependencies.
The problem with percentage rollouts
The trickiest part of feature flags isn't the on/off toggle — it's percentage
rollouts. You want 20% of users to see a new feature. But which 20%?
The naive approach uses Math.random():
if (Math.random() < 0.2) showNewFeature()
This is wrong. The same user might see the feature on one request and not on
the next. The experience flickers. Worse, if you refresh the percentage to 30%,
you can't guarantee the original 20% still see it.
The right approach is deterministic hashing. Given the same user and the same
flag, always produce the same result. I used the FNV-1a 32-bit hash algorithm
— zero dependencies, fast, excellent distribution:
function fnv1a32(input: string): number {
const bytes = new TextEncoder().encode(input);
let hash = 2166136261; // FNV offset basis
for (let i = 0; i < bytes.length; i++) {
hash ^= bytes[i]!;
hash = Math.imul(hash, 16777619) >>> 0; // FNV prime, unsigned
}
return hash;
}
// Usage: hash flagName + userId, map to 0-100
const bucket = fnv1a32("newDashboard:usr_123") % 100;
const enabled = bucket < 20; // 20% rollout
The same user always lands in the same bucket. When you increase from 20% to
30%, the original 20% still see the feature — you're just expanding the bucket,
not reshuffling.
AsyncLocalStorage eliminates context threading
The second hard problem: how does flags.isEnabled() know who the current
user is? You could pass userId everywhere, but that's prop drilling through
your entire codebase.
Node.js has a built-in solution: AsyncLocalStorage. Set context once at the
request boundary, read it from anywhere in the async call chain:
// Middleware sets context once
app.use(flags.middleware())
// Anywhere in your codebase — no userId parameter needed
if (await flags.isEnabled("newDashboard")) {
return renderNewDashboard()
}
The middleware stores userId, email, and groups in AsyncLocalStorage. Every
isEnabled() call in that request's async tree reads it automatically.
OpenTelemetry uses the same pattern for trace propagation.
What featurekit looks like in practice
import { createFlags, fileAdapter } from "featurekit"
const flags = createFlags({
source: fileAdapter({ path: "./flags.json" }),
defaults: { newDashboard: false, betaFeature: false },
})
app.use(flags.middleware())
app.get("/dashboard", async (_req, res) => {
if (await flags.isEnabled("newDashboard")) {
return res.json({ version: "new" })
}
res.json({ version: "classic" })
})
Your flags.json:
{
"newDashboard": true,
"betaFeature": {
"enabled": true,
"percentage": 20,
"users": ["alice@company.com"],
"groups": ["beta-testers"]
}
}
Flag names are fully typed — flags.isEnabled("typo") is a compile-time
error. The type flows from the defaults object, same pattern as Zod's
schema inference.
Hot reloading without restart
The file adapter uses fs.watch to detect changes. When flags.json changes,
flags update in memory immediately — no restart needed. If the file becomes
invalid JSON, the adapter logs a warning and keeps the previous valid state.
It never crashes your application.
For production, swap to the env adapter — reads from FEATUREKIT_FLAGS as a
JSON string. Works everywhere: serverless, edge, containers.
Install and try it
npm install featurekit
Full source on GitHub.
If you've ever copy-pasted a LaunchDarkly setup you couldn't afford to keep,
I'd love to hear what you think.
Top comments (0)