DEV Community

Sadat Nazarli
Sadat Nazarli

Posted on

I built the dotenv of feature flags for TypeScript - here's how deterministic rollouts work

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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" })
})
Enter fullscreen mode Exit fullscreen mode

Your flags.json:

{
  "newDashboard": true,
  "betaFeature": {
    "enabled": true,
    "percentage": 20,
    "users": ["alice@company.com"],
    "groups": ["beta-testers"]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)