Skip to Content
FeatureRollouts

Rollouts

@evanion/feature 0.1.0 and its patch releases up to 0.1.1, the version on npm. Values on this page were produced by running the source at e53b2d8, a later commit than the 0.1.0 tag, and are not re-executed.

To release a feature to a fraction of your users, you use a rollout. A rule’s rollout holds a feature to a percentage of customers, and inRollout decides whether one customer is in. A customer in the 10% stays in it on every request, process and device.

Stable, Deterministic Bucketing

When rolling out features, you must ensure that a user’s experience does not flicker. @evanion/feature keeps a customer in a rollout from one page to the next, because a customer who loses the new checkout between two pages cannot finish an order and nobody can reproduce the bug they report. It computes a bucket from a seed and a targeting value, using the hash behind the inRollout call Getting Started asked about two customers.

customer id
inRollout('cust-0101', 10, 'new-checkout')

true

Produced by @evanion/feature 0.1.0 at e53b2d8. Not re-executed. Run it live on the unreleased page.

The probe calls inRollout(id, 10, 'new-checkout') for any id you type, and the section below derives the bucket behind its answer.

How a Bucket is Derived

To determine membership, the library hashes the seed and the targetingKey (e.g., a customer ID) with MurmurHash3 into a bucket in [0, 1), and a customer is in a rollout when the bucket times 100 is below percent.

This approach provides these guarantees:

  1. Stability: The result is a pure function of the input. The same user always lands in the same bucket.
  2. Decorrelation: Because the seed defaults to the feature key, a user will land in different buckets for different features. A customer in the new checkout’s 1% is not, for that reason, in express pickup’s 1%.
  3. Monotonicity: The bucket value is independent of the percentage threshold. If you raise a rollout from 10% to 25%, you only add new members to the cohort. No one who already had the feature will suddenly lose it. Lowering it removes the highest buckets first.

cust-0101 sits in the lowest 5% of the new checkout’s range, so it is in at 10% and still in at 25%:

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

cust-0007 below sits near the top of the new checkout’s range and near the bottom of express pickup’s:

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

Defining a Rollout

To implement a partial release in @evanion/feature, you add a rollout as one more condition of the rule that carries it. A rule that has both a when list and a rollout matches only if the customer satisfies the conditions AND falls within the bucket range.

import { } from '@evanion/feature'; const = ([ { : 'gift-cards', : true, : [ { : { : 25, : 'targetingKey', : 'new-checkout', }, }, ], }, ]); .('gift-cards', { : 'cust-0101' }); // -> true .('gift-cards', { : 'cust-0007' }); // -> false
FieldDefaultMeaning
percent(Required)The threshold (0 to 100).
by'targetingKey'The field in the context used for bucketing.
seedThe feature keyThe salt used for the hash.

A feature-level seed on the definition replaces the key for every rollout in that feature, and a rule’s rollout.seed replaces it for that rule alone. Set a seed only to correlate two features deliberately, for example to keep a pair of flags rolling out to the same cohort.

Edge Cases and Constraints

When configuring your rollout, be aware of how the bucketing logic handles specific data scenarios:

  • Missing Targeting Keys: If the context does not carry the field specified in by, the rollout is automatically rejected, even at 100%. So a context with no customer id never joins a rollout.
  • Data Types: Only string and number are usable as bucketing values. Objects, booleans, or nulls are treated as absent.
  • Exact Bounds: A percent of 0 includes nobody; 100 includes every context that carries the bucketing field.
import { , } from '@evanion/feature'; const = ([ { : 'new-checkout', : true, : [{ : 'everyone', : { : 100 } }], }, ]); .('new-checkout', { : 'cust-0042' }); // -> true .('new-checkout', {}); // -> false ('cust-0101', 0, 'new-checkout'); // -> false

Why MurmurHash3?

To achieve a uniform distribution, @evanion/feature avoids simple modulus operations (e.g., hash % 100). This is problematic because it only uses the “low bits” of the hash, and a hash such as FNV-1a mixes those bits poorly, which skews the distribution.

Instead, the library uses MurmurHash3’s finalization to mix the entire 32-bit word and takes the bucket from all 32 bits, ensuring a uniform distribution over 2^32 buckets. It also length-prefixes the seed, so a seed and value pair such as (ab, c) can never hash the same as (a, bc), and flags whose keys share a prefix do not share buckets.

Exported Primitives

For snapshot testing or predictive analysis (e.g., “Which 1,000 customers will see this rollout?”), @evanion/feature exports the underlying primitives bucketOf, inRollout and murmur3:

import { , } from '@evanion/feature'; const = ['cust-0007', 'cust-0042', 'cust-0101', 'cust-0113']; .(() => (, 10, 'new-checkout')); // -> ['cust-0101', 'cust-0113'] ('cust-0113', 'new-checkout'); // -> 0.03226795047521591

These functions allow you to compute the result of a rollout without creating a full feature store. inRollout(value, percent, seed) is the whole of what a rollout decides once the bucketing value is known, and bucketOf(value, seed) returns the bucket it compares.

Variants splits the customers a feature reaches across named versions, bucketing them the same way under a separate seed.

Last updated on