Skip to Content
FeatureRollouts

These pages document @evanion/feature 0.1.1, the version on npm.

Rollouts

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.

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.

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

Watching a Rollout Widen

The control below holds one flag, new-gallery, rolling out to a share of visitors on strategy-game pages only. A build renders many pages and knows each page’s category, so it answers the Azul page (a family game) before deploy: no visitor of that page can get the new gallery. The Brass page waits, because only a request carries a visitor. Move the slider and the visitors the rollout reaches fill in without anyone dropping out.

Decided before deploy

Set once for every visitor when the site builds.

Brass: Birmingham

Brass: Birmingham is a strategy game, so the rollout can reach its visitors.

Old gallery
New gallery

The new gallery waits for the visitor.

Decided when a visitor arrives

Set for each visitor when they arrive.

11 of 48 visitors see the new gallery.

Raising the share only adds visitors; nobody who has the new gallery loses it.

Each dot is a visitor, told apart by their id. Filled means they see the new gallery. The same visitor always gets the same answer.

Tap or hover a dot to see that visitor.

11 of 48 visitors see the new gallery.

Your flags

createFeatures([
  { key: 'new-gallery', enabled: true,
    rules: [{
      when: [{ field: 'category', op: 'eq', value: 'strategy' }],
      rollout: { percent: 25, by: 'targetingKey' },
    }] },
])

// before deploy: one call per page
features.plan({ game, category: 'strategy' })
// when a visitor arrives: one call per request
features.resolve({ game, category, targetingKey: visitor.id })

The first lane is plan() called with the page’s game and category and no visitor. The second is resolve() called once per visitor with the same context and that visitor’s id as the targetingKey.

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