Skip to Content
FeatureRollouts

Rollouts

Not on npm yet

npm install @evanion/feature does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.

A rollout holds a resolved feature to a share of customers. Baize is putting its new checkout in front of a tenth of them, and the tenth has to be the same tenth on the next request, in the next process, and tomorrow.

import { } from '@evanion/feature'; ('cust-0101', 10, 'new-checkout'); // -> true ('cust-0042', 10, 'new-checkout'); // -> false
inRollout('cust-0101', 10, 'new-checkout')

true

Retype the id. The same customer always lands in the same place, and about one in ten is in.

Retype the id. inRollout is a pure function of the customer and the flag key, so one customer’s answer never moves, a neighbouring id lands anywhere in the range, and about one id in ten is in. A coin flip at ten percent gives the same proportion and none of that.

What a rollout is written as

{ rollout: { percent: 25, by: 'targetingKey', seed: 'holiday-cohort' } }

A rollout is one more conjunct of the rule that carries it. A rule with both when and rollout matches only for a context that satisfies the conditions and falls inside the bucket range.

FieldDefaultMeaning
percentrequiredthe threshold, 0 to 100
by'targetingKey'which context field to bucket on
seedthe feature keywhat the hash is seeded with

How a bucket is derived

The bucket is a MurmurHash3 of the seed and the bucketing value, normalised over 2^32 buckets into [0, 1). Three properties follow.

Stable

The bucket is a pure hash of the (seed, value) pair. No randomness, no clock, no process state, so every process and every evaluation agrees:

import { , } from '@evanion/feature'; ('cust-0101', 'new-checkout'); // -> 0.04385687271133065 ('cust-0101', 25, 'new-checkout'); // -> true ('cust-0007', 25, 'new-checkout'); // -> false

Decorrelated across features

The seed defaults to the feature key, so one customer hashes to an unrelated bucket per feature. cust-0007 sits at the top of the new checkout’s range and at the very bottom of express pickup’s:

import { } from '@evanion/feature'; ('cust-0007', 'new-checkout'); // -> 0.912969104712829 ('cust-0007', 'express-pickup'); // -> 0.007160091772675514

Seeding with a constant instead is what puts someone in every rollout or in none. Set seed explicitly only to correlate two features deliberately — a pair of flags that must reach the same cohort.

Monotonic in the percentage

The bucket does not depend on percent at all; percent is only a threshold on it. Raising a percentage can therefore only add members, never move one out. Lowering it removes the highest buckets.

A customer who saw the new checkout at 10% still sees it at 25%.

Edges

inRollout('cust-0101', 0, 'seed'); // false — 0% includes nobody inRollout('cust-0101', 100, 'seed'); // true — 100% includes everybody

Comparison is strictly below the threshold, and every bucket is strictly below 1, so both ends are exact.

A context with no value for by does not match the rollout:

features.resolve({ now }); // no targetingKey // rules: [{ rule: 'after-launch', matched: false, // rollout: { percent: 25, by: 'targetingKey', member: false } }]

bucket is absent from that outcome, because there was nothing to bucket. Defaulting to “in” would ramp a rollout to everyone whose context happens to be incomplete, which is the population you least want it to reach first.

Only a string or a number is a usable bucketing value. Anything else — an object, a boolean, null — is treated as absent.

Why not FNV-1a over a concatenation

GrowthBook’s pre-v2 hashFnv32a(value + seed) % 1000 fails decorrelation for two separate reasons, and this departs from it on both.

Concatenating value and seed with no unambiguous boundary makes the pairs ('ab', 'c') and ('a', 'bc') the same input, so flags whose keys share a prefix share buckets. The seed here is length-prefixed, which is unambiguous for every possible input — no separator character can be, since any separator may itself occur in a key or a targeting value.

FNV-1a also has weak avalanche in its low bits, and a modulus reads exactly those. MurmurHash3’s finalisation mixes the whole word, and the bucket is taken from all 32 bits rather than from a modulus — which is also what makes the granularity fine enough that a percentage change adds buckets instead of reshuffling them.

MurmurHash3 over a normalised 32-bit space is Unleash’s approach, with the group id defaulting to the flag name: the same defaulting as seed here.

The primitives are exported

import { bucketOf, inRollout, murmur3 } from '@evanion/feature';

For snapshot tooling and tests: computing which accounts a ramp will reach before applying it, or asserting that a cohort did not move across a deploy. Nothing in the library reads them back.

Last updated on