Keeping flag evaluations stable

Feature Flags are pure functions – same inputs, same outputs. The most common way to break that contract is to change the distinct_id between evaluations, which happens naturally when a user transitions from anonymous to identified.

This page covers how to keep the hash input stable so flags don't flip during authentication transitions. If you haven't already, read identity resolution first – a stable identity strategy eliminates most flag stability problems before they start.

Why the hash input changes

When a user starts anonymous and then logs in:

hash("my-experiment", "anon-uuid-123") → 0.31 → variant "test"
hash("my-experiment", "user-456") → 0.72 → variant "control"

Same person, different variant. The hash function worked correctly both times – it received different inputs and produced different outputs.

For Experiments, this is data corruption: you experienced "test" but may now be counted in "control." For feature rollouts, you see a feature appear and then disappear on login.

Start with your identity strategy

The best way to keep flag evaluations stable is to prevent the distinct_id from changing in the first place. The identity resolution guide covers three approaches:

  • Golden path: stable ID from first touch – assign a durable ID before any flag evaluation happens. The distinct_id never changes, so the hash never changes. This is the recommended approach.
  • Common path: anonymous then identify on login – the SDK generates an anonymous ID, then identify() links it to a known user. This changes the distinct_id, which changes the hash. The mitigations below exist for this path.
  • Server-side bootstrap with a known ID – if you have server rendering, you already know who's visiting before the page loads. Bootstrap with their stable ID and the anonymous-to-identified transition never happens.

If you can use the golden path or server-side bootstrap, the rest of this page is unnecessary. The sections below are for when the distinct_id will change and you need to manage that.

Flag-specific mitigations

When your identity strategy involves a distinct_id change (the common path), PostHog offers two mechanisms to keep flag values stable across that transition.

Hashes on $device_id instead of distinct_id. Since the device ID doesn't change on identify(), the flag value stays consistent across the authentication boundary.

See Device bucketing for setup.

Device bucketingExperience continuity
Database costNoneRead on every evaluation, write on first identify
Local evaluationSupportedNot supported – forces network round-trip
BootstrappingCompatibleNot compatible
person_profiles: 'identified_only'WorksDoesn't work
Cross-device consistencyNo (per-device)No (per-person, but only after identify)
Known issues$device_id can be lost on cookie clearsPostHog-js #2623: values can change after identify

Experience continuity

Pins a stable hash key to a person record in Postgres. Enabled per-flag via ensure_experience_continuity. The server remembers which hash key was used for the first evaluation and reuses it on all subsequent evaluations, even after the distinct_id changes.

See Creating Feature Flags – persisting across authentication for setup.

Use experience continuity only when device bucketing and the stable ID approaches aren't feasible. It comes at a cost: a database read and write on every evaluation, no support for local evaluation, and a known issue where values can still change after identify().

How "persist flags across authentication" works internally

When a flag has this setting enabled (the ensure_experience_continuity API field), the flags service uses the original anonymous hash key for evaluations even after the distinct_id changes:

  1. Override write. When the SDK calls /flags with both distinct_id (the new identified ID) and $anon_distinct_id (the previous anonymous ID), the flags service writes a hash key override to the database. The override maps the person to the original anonymous ID for that flag key.

  2. Override read. On subsequent /flags requests for the identified distinct_id, the service looks up stored overrides and uses the original anonymous ID as the hash input instead of the current distinct_id.

  3. Write timing matters. Overrides are written during /flags requests, not during identify() events. The web SDK handles this automatically – after identify(), it reloads flags and sends $anon_distinct_id along with the request, which triggers the override write. Server-side SDKs must include $anon_distinct_id in their flag evaluation calls – either as a top-level field in direct /flags HTTP requests, or via person_properties (the Rust flags service checks both locations, with top-level taking precedence).

  4. Requires person_profiles: 'always'. The override is stored against a person_id. With identified_only, no person record exists during anonymous activity, so the write fails silently. The SDK clears $anon_distinct_id after the request regardless, so no retry happens.

  5. Optimization for 100% rollout. Flags at 100% rollout with no multivariate variants return the same value regardless of hash input, so the database lookup is skipped automatically.

Cross-device consistency

Device-scoped solutions (device bucketing, cookie-based stable IDs) don't span devices. If a user logs in on their phone and laptop, each device hashes independently.

For cross-device consistency, target flags on person properties instead of relying on the hash:

Flag condition: where plan = "enterprise" → 100% rollout
Flag condition: where cohort = "beta-users" → show feature

Person properties resolve server-side against the person record. The same person matches the same conditions regardless of device. This reframes the question: instead of "how do I make the hash stable across devices," ask "how do I target users based on who they are?"

Multi-platform consistency

If the same user has flags evaluated on multiple platforms simultaneously (web SDK, mobile SDK, and backend local evaluation), consistency depends on the flag type:

Property-targeted flags resolve against the person record, so they're consistent as long as properties are synced across platforms. Ensure every SDK has access to the properties you're targeting on – see cross-environment consistency for how to coordinate this.

Percentage-rollout flags (including Experiments) use the hash of flag_key + distinct_id. The hash is deterministic, so if every platform uses the same distinct_id, they produce the same result. Ensure your backend uses the same distinct_id string as your client SDKs.

To guarantee consistency across platforms for Experiments, evaluate flags on your server and distribute results to clients via bootstrap or your own API response – one evaluation, one authoritative result.

Choosing the right pattern

RequirementBest approach
Same flag value before and after login (single device)Stable ID or device bucketing
Same flag value across devices (logged-in users)Target on person properties
Same flag value across devices (anonymous users)Not solvable without cross-device identity
Maximum debuggability and controlServer-side local eval with explicit stable ID
Quick fix, minimal code changesDevice bucketing
Can't change identity flow at allExperience continuity

Further reading

Community questions

Was this page useful?

Questions about this page? or post a community question.