Shipeasy
Flags & ExperimentsConfigs

Targeting & rollouts

Express "show this to the right users" with attribute rules, deterministic percentage rollouts, and per-feature-flag salts. The rules engine, in detail.

Production readyOn this page · 10 min readUpdated · May 3, 2026Works with · Server SDK · Browser SDK

A targeting rule on a feature flag is a predicate of the form attr op value. Multiple rules on a feature flag are ANDed together. To OR, use the in operator with an array, or split into separate feature flags and combine in code.

This page is the operational reference for the rules engine: every operator, the bucketing math, and the patterns we've seen work for safe rollouts.

Operators

OperatorWorks onExample
eq / neqanyplan eq "pro"
in / not_inscalar vs arraycountry in ["US","CA","MX"]
gt / gte / lt / ltenumberstenure_days gt 30
containsstring⊂string, array∋valueroles contains "admin"
starts_with / ends_withstrings onlyemail ends_with "@acme.com"
regexstrings onlyemail regex "@acme\\.com$"
version_gte / version_ltsemver stringsapp_version version_gte "2.4.0"

Numeric ops coerce strings that look like numbers — "100" gt 50 is true. They never coerce booleans. regex is anchored only when you anchor it (^ / $); we run it in the SDK, no server round-trip.

Common rule shapes

// Beta cohort — explicit list
{ attr: "user_id", op: "in", value: ["u_1", "u_2", "u_3"] }

// Internal users only
{ attr: "email", op: "regex", value: "@acme\\.com$" }

// Pro plan in supported regions
[
  { attr: "plan",    op: "eq", value: "pro" },
  { attr: "country", op: "in", value: ["US","CA","UK"] },
]

// Long-tenured power users
[
  { attr: "tenure_days", op: "gte", value: 60 },
  { attr: "team_size",   op: "gte", value: 5 },
]

// Mobile app, version-gated
[
  { attr: "platform",    op: "eq",          value: "ios" },
  { attr: "app_version", op: "version_gte", value: "2.4.0" },
]

The dashboard shape is JSON, so you can copy these straight in — paste the array into the feature flag's targeting editor and click Save. The same JSON can be patched via the Admin API.

Rollout percentages

Rollout is deterministic. The SDK computes:

bucket = murmur3(salt + ":" + (user_id ?? anonymous_id)) % 10000
true   = bucket < rolloutPct

That gives you three properties:

  • Stable: the same user always gets the same bucket for the same feature flag. No flicker on reloads, no flicker across servers.
  • Independent: changing one feature flag's rollout doesn't move users on another feature flag (different salts).
  • Fair: bucketing is uniform; small rollouts give a representative sample.

Internally we work in basis points (0..10000), so you can roll out at 0.01% granularity if you really need it.

Rolling out safely

A safe rollout schedule for an unknown-risk change:

0%   → enable rule + dogfood with overrides
1%   → 1 hour, watch error rate + p95
5%   → 4 hours
25%  → overnight
50%  → 1 day
100% → keep an eye on it for a week, then delete the flag

For a known-low-risk change you can compress this. For anything load-bearing (auth, billing, the data path) — don't. The whole point of a gradual rollout is to find the surprise before it's 100% of your traffic.

Bumping percentage never reshuffles

Going from 5% → 25% adds users to the bucket — the original 5% is still in. You won't see a user flap from true back to false as you ramp up. The only way to reshuffle is to bump the salt.

The salt

Each feature flag has its own salt. If you ever want to re-shuffle the rollout buckets without changing the percentage (say, to avoid the same 5% always being chosen for every feature flag you ever roll out), bump the salt. CLI flags update doesn't expose a --salt flag today — bump it on the feature flag via the Admin API or edit the feature flag's JSON in the dashboard.

That's rare. The default salt is fine for almost every case. The salt is also why two feature flags at 50% don't serve the same 50% of users — different salts, different buckets, statistically independent.

Combining rules + rollout

Rules are checked before rollout. Two practical implications:

  1. "100% of plan = pro" means all pro users get true. The rollout filter doesn't fight the rule.
  2. "5% of plan = pro" means 5% of pros, not "5% of everyone, then keep only pros". The bucket is computed against the pre-filtered population.

This is what you almost always want. If you ever need "this proportion of the entire user base, but only if they're pro" — that's an experiment and we have purpose-built tools for it.

Bucketing by something other than user_id

Default: bucketing key is user_id ?? anonymous_id. Override per-call:

// Set the gate's bucketing key in the dashboard (or PATCH the gate's
// `bucket_by` field via the Admin API) so every read uses the same key.
const flags = new Client(user);
flags.getFlag("new-team-ui");

Or on the feature flag itself in the dashboard. Use this in B2B so all teammates of an account see the same variant — half a team on the new UI and half on the old is a confusing user experience and an unanalysable rollout.

Don't bucket by mutable attributes

Bucketing on email or plan means a user changes bucket whenever the attribute changes. They'll see the feature flicker on and off. Always bucket on something stable: user_id, account_id, or anonymous_id.

First-match-wins evaluation

When a feature flag has multiple rule groups (the dashboard's "OR an additional cohort" affordance), the SDK evaluates them top to bottom and takes the first match. Each group can have its own rollout percentage and bucketing key.

multi-group rule
[
  {
    "match": [{ "attr": "internal", "op": "eq", "value": true }],
    "rollout": 10000,
    "bucketBy": "user_id"
  },
  {
    "match": [{ "attr": "plan", "op": "eq", "value": "pro" }],
    "rollout": 500,
    "bucketBy": "account_id"
  }
]

Reads as: "100% of internal users; 5% of pro accounts (consistent across teammates); everyone else gets the default."

The order matters. If a user is both internal and pro, the first matching group wins — they get the 100% bucket, not the 5% one.

Overrides vs rules

Use rules for cohorts you can describe — "all internal users", "all pro accounts". They scale.

Use overrides for individual user IDs — QA accounts, customers reporting a specific bug, demos. They're the trump card and bypass everything else.

If you find yourself adding many overrides, that's a smell — promote them to a rule keyed on a internal: true or early_access: true attribute and you'll thank yourself when the QA team turns over.

Attributes that travel

Whatever attributes you bind onto the Client (via the configure() attributes transform) are also available to:

So define attributes once at the request boundary and pass the same user shape everywhere:

src/lib/auth.ts
export function buildEvalCtx(req: Request) {
  const session = getSession(req);
  return {
    user_id: session.user_id,
    plan: session.plan,
    country: geoFromReq(req),
    tenure_days: daysSince(session.created_at),
    app_version: req.headers.get("x-app-version") ?? "0.0.0",
  };
}

Debugging a rule

The dashboard's Test evaluator panel takes a JSON context and shows the full evaluation trace — which rule matched, which didn't, what bucket the user landed in. Indispensable when a customer says "I should be in the beta, why am I not?". From your own code, flags.getFlag(...) returns the same evaluator result on every read — log the user context plus the result for the same answer in production.

NEXT

Promote to an experiment.

When you want a defensible answer (not just a ramp), turn the feature flag into an A/B test and let Shipeasy run the statistics.

Set a rollout
$shipeasy flags rollout my-flag 25
List flags
$shipeasy flags list
Was this page helpful?
✎ Edit this page

On this page