How Bucketing Works

Bucketing is the mechanism behind percentage rollouts.

When a flag has a per­cent­age roll­out, Fea­ture Flags needs a way to decide whether a vis­i­tor qual­i­fies to see the fea­ture. It does this by cre­at­ing a buck­et” num­ber for every vis­i­tor and check­ing whether than num­ber falls with­in the roll­out range.

This page explains how buck­et­ing works, what con­trols the buck­et input, and how to use buck­et keys to change the unit of a rollout.

Cre­at­ing a Buck­et #

The plu­g­in hash­es a buck­et key togeth­er with the flag han­dle to pro­duce a num­ber from 0 to 99:

return abs(crc32($bucketKey . ':' . $handle)) % 100;

A flag with roll­out per­cent­age of 30 enables for any vis­i­tor whose buck­et is 0 – 29. This means:

  • The same input always pro­duces the same buck­et for the same flag. A vis­i­tor who qual­i­fies at 30% will still qual­i­fy when you increase to 50%.
  • Rais­ing the per­cent­age from 25% to 50% adds buck­ets 25 – 49. Nobody in 0 – 24 los­es access. Low­er­ing the per­cent­age does remove vis­i­tors from the high end.
  • The flag han­dle is part of the hash, so a vis­i­tor at buck­et 12 for flag-a won’t nec­es­sar­i­ly be at buck­et 12 for flag-b. Dif­fer­ent flags get inde­pen­dent distributions.
  • There’s no data­base table track­ing cohorts. The buck­et is com­put­ed on the fly from the input string.

Buck­et keys #

The buck­et key para­me­ter lets you change what the roll­out is per” (or the unit of the percentage).

The buck­et key is hashed with the flag han­dle, and the plu­g­in resolves it in this order:

  1. Explic­it buck­et key: if you pass one to isEnabled(), it’s used directly
  2. User ID: if the vis­i­tor is logged in, their Craft user ID (as a string)
  3. Anony­mous vis­i­tor cook­ie: a UUID stored in the _ff_vid cookie
  4. Noth­ing avail­able: the roll­out check is skipped and the flag returns false

This res­o­lu­tion hap­pens auto­mat­i­cal­ly. If you don’t pass a buck­et key and the vis­i­tor is logged in, their user ID is the buck­et input. If they’re logged out and have the vis­i­tor cook­ie, that UUID is the input.

Default: per-user

With­out a explic­it buck­et key passed in to isEnabled(), each vis­i­tor gets their own buck­et. Logged-in users are buck­et­ed by their user ID, and anony­mous vis­i­tors by their cook­ie UUID. These are inde­pen­dent pools: a 30% roll­out means rough­ly 30% of logged-in users and rough­ly 30% of anony­mous vis­i­tors, com­put­ed sep­a­rate­ly. Two vis­i­tors on the same page may see dif­fer­ent results.

{# 30% of users see the new checkout #}
{% if craft.featureFlags.isEnabled('new-checkout') %}

Per-entry

Pass entry.id as the buck­et key, and the roll­out applies across entries instead of users. 30% of your entries will have the flag enabled, and every vis­i­tor to a giv­en entry sees the same result.

{# 30% of entries show the experimental hero layout #}
{% if craft.featureFlags.isEnabled('hero-experiment', entry.id) %}

This is use­ful for con­tent-lev­el exper­i­ments, like test­ing a new lay­out on a sub­set of blog posts or prod­uct pages.

Any string

The buck­et key can be any string. The plu­g­in doesn’t inter­pret it, it just hash­es it. Use what­ev­er dimen­sion makes sense for your rollout.


Buck­et keys and tar­get­ing rules #

Buck­et keys only affect the per­cent­age roll­out step of eval­u­a­tion. They have no effect on:

  • Whether the flag is enabled or disabled
  • Whether a tar­get­ing rule matches
  • Whether the flag has expired

If a flag has no rolloutPercentage, the buck­et key is ignored entire­ly. If a tar­get­ing rule match­es and the flag uses the default all strat­e­gy, the buck­et key is also irrel­e­vant. The rule match short-cir­cuits before the roll­out check.

With the rule strat­e­gy, the buck­et key mat­ters even when a rule match­es, because the roll­out per­cent­age fil­ters with­in the matched group. See Per­cent­age Roll­outs for details on how the two strate­gies interact.


The buck­et key is a replace­ment, not a fil­ter #

This is an impor­tant dis­tinc­tion: when you pass a buck­et key, it replaces the user’s iden­ti­ty in the hash entire­ly. It doesn’t com­bine with it. What­ev­er string you pass becomes the only thing that deter­mines the outcome.

This is intu­itive when the buck­et key is entry.id because you want every vis­i­tor to the same entry to see the same result. The entry ID replaces the user ID, and the roll­out splits across entries instead of users.

But it’s sur­pris­ing when the buck­et key has few pos­si­ble val­ues. Say you pass a user group ID as the buck­et key, expect­ing 20% of each group.” That’s not what hap­pens. Every user in the same group pass­es in the same key (the user group id), so they all com­pute the same buck­et num­ber, so they all get the same result. The plu­g­in can’t give 20% of a group the fea­ture because every user in that group is iden­ti­cal from the bucket’s perspective.

For exam­ple, if your site has 4 user groups and you use the group ID as the buck­et key with a 20% rollout:

Group IDBuck­etInclud­ed?
190No
266No
377No
446No

None of the 4 buck­et num­bers fall below 20, so 0% of users see the fea­ture, not 20%. At 50%, only group 4 (buck­et 46) qual­i­fies, mean­ing 100% of that group sees it and 0% of the oth­er three. It’s all-or-noth­ing per group because there are only 4 pos­si­ble outcomes.

Choos­ing good buck­et keys #

Buck­et keys work best with many dis­tinct val­ues. User IDs, entry IDs, UUIDs, and ses­sion IDs all pro­duce hun­dreds or thou­sands of dis­tinct buck­et num­bers, giv­ing a smooth approx­i­ma­tion of the tar­get percentage.

Low-car­di­nal­i­ty inputs, like group IDs, plan tiers, locale codes, boolean fields, pro­duce only a hand­ful of buck­et num­bers. The per­cent­age can’t dis­trib­ute smooth­ly across a small num­ber of val­ues. A 50% roll­out across 4 val­ues gives you all-or-noth­ing per val­ue, not 50% of each.

Good buck­et keys

Buck­et keyUnit of roll­outWhy it works
User ID (default)Per-userPoten­tial­ly thou­sands of dis­tinct values
Entry IDPer-entryEach piece of con­tent gets its own bucket
Ses­sion IDPer-ses­sionCon­sis­tent with­in a vis­it, re-rolls next time
Account/​organization IDPer-com­pa­nyAll users in the same org see the same result
Order/​cart IDPer-orderCon­sis­tent expe­ri­ence through­out a transaction
Anony­mous vis­i­tor cookiePer-vis­i­torBuilt-in default for logged-out traffic

Sub­op­ti­mal buck­et keys

Buck­et keyProb­lem
User group IDMost sites have 3 – 10 groups. All-or-noth­ing per group.
Sub­scrip­tion plan handleA hand­ful of tiers. Same issue.
Locale / lan­guage codeTyp­i­cal­ly 2 – 10 val­ues. Entire locales get includ­ed or excluded.
Boolean field ("yes" / "no")Two val­ues, two buck­ets. No per­cent­age con­trol at all.
Entry type handleA few dis­tinct val­ues. Roll­out can’t dis­trib­ute smoothly.
Envi­ron­ment name2 – 4 val­ues. Use an envi­ron­ment tar­get­ing rule instead.

For low-car­di­nal­i­ty dimen­sions, use tar­get­ing rules instead of buck­et keys. Rules let you select a spe­cif­ic group, plan, or envi­ron­ment direct­ly. If you need a per­cent­age with­in that group, com­bine the rule with a per­cent­age roll­out. The roll­out will buck­et by indi­vid­ual user ID (the default), giv­ing a smooth distribution.

Anony­mous vis­i­tors #

For logged-out vis­i­tors with no explic­it buck­et key, the plu­g­in gen­er­ates a UUID and stores it in a first-par­ty cook­ie so the same vis­i­tor gets the same buck­et across page views and return visits.

Set anonymousCookieTtl to 0 to dis­able anony­mous vis­i­tor track­ing. Per­cent­age roll­outs will then only work for logged-in users or when an explic­it buck­et key is provided.