kilden_
← blog

Feature flags that target what users did

KE
Kilden Engineering 10 Jun 2026 · 3 min read
on this page ▾

Somewhere in your flag tool there's a table that remembers which users got the new checkout. It has to be written on the hot path, synced across nodes, and kept from drifting out of agreement with the config. Ours doesn't exist — and that absence is the most interesting thing about Kilden's flags.

Assignment with no memory

Rollout percentages need to be sticky: a user in the 30% today must still be in it tomorrow. The tempting way to get stickiness is to persist assignments. We don't. Assignment is a pure function:

text · bucketing
bucket(flag, id)  = first 8 bytes of SHA-256(flag_key + ":" + distinct_id) / 2^64 → [0, 100)
variant(flag, id) = same, with a ":variant" suffix, walking the cumulative weights

Same input, same result, forever, on any node — no coordination, no writes in the hot path, no assignment table to drift out of sync with config. The hash space is a ruler, and the rollout percentage is just a cut line on it:

The hash space from 0 to 100 with a rollout cut at 30 percent extended to 50 percent; users below the cut are in, users above are out, and raising the cut only adds users. sha-256(flag_key : distinct_id) → bucket out 0 30 50 100 30% → 50% only adds users: everyone below the old cut stays exactly where the hash put them. No assignment table. No writes. The ruler is the function.

Raising a rollout from 30% to 50% only adds users — everyone below 30 in the hash space stays exactly where the hash put them. And the variant hash is independent of the rollout hash on purpose, so which variant you get never correlates with whether you got in at all.

The one tradeoff we accepted with open eyes: bucketing depends on distinct_id, so a user near the cutoff can flip sides when identify() switches their id from anonymous to real. The stateless evaluation is worth that flicker — the alternative (persisted assignments) trades a visible edge case for an invisible class of drift bugs, and the SDK reloads flags after identify to keep the window small.

Cohorts: targeting on behavior

Most flag tools can target who users are — plan, country, email domain. Sitting on an event pipeline means Kilden's flags can target what users did: "triggered export_report at least twice in the last 7 days", "saw the new onboarding". Cohorts are materialized from the events store into fast membership sets, so flag evaluation stays cheap — the expensive query runs on a schedule, not per decide call.

That collapses a whole category of glue code. Nobody exports "power users" from the analytics tool into the flag tool: it's one system reading its own events. The cohort is a query over data the flags already live next to.

The decide path

Flag evaluation is a dedicated, deliberately small service. It holds config in memory, evaluates the pure function, and answers — measured locally at ~17k rps with a p99 around 2 milliseconds when caches are warm. Every evaluation can emit a $feature_flag_called exposure event back into the pipeline, which is what makes experiment analysis honest: you chart conversion among users who actually saw the flag, not among everyone the config theoretically included.

Flags that read events, and flags that write events. The loop is the product.

$ npm install kilden
Analytics, feature flags, campaigns and session replay — one event pipeline, one SDK.

1M events/month on the free tier. Set up in about two minutes.

new posts, monthly Engineering and product notes. No spam, unsubscribe anytime.