Keeping flag evaluations stable
Contents
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:
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_idnever 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 thedistinct_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.
Device bucketing (recommended)
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 bucketing | Experience continuity | |
|---|---|---|
| Database cost | None | Read on every evaluation, write on first identify |
| Local evaluation | Supported | Not supported – forces network round-trip |
| Bootstrapping | Compatible | Not compatible |
person_profiles: 'identified_only' | Works | Doesn't work |
| Cross-device consistency | No (per-device) | No (per-person, but only after identify) |
| Known issues | $device_id can be lost on cookie clears | PostHog-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:
Override write. When the SDK calls
/flagswith bothdistinct_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.Override read. On subsequent
/flagsrequests for the identifieddistinct_id, the service looks up stored overrides and uses the original anonymous ID as the hash input instead of the currentdistinct_id.Write timing matters. Overrides are written during
/flagsrequests, not duringidentify()events. The web SDK handles this automatically – afteridentify(), it reloads flags and sends$anon_distinct_idalong with the request, which triggers the override write. Server-side SDKs must include$anon_distinct_idin their flag evaluation calls – either as a top-level field in direct/flagsHTTP requests, or viaperson_properties(the Rust flags service checks both locations, with top-level taking precedence).Requires
person_profiles: 'always'. The override is stored against aperson_id. Withidentified_only, no person record exists during anonymous activity, so the write fails silently. The SDK clears$anon_distinct_idafter the request regardless, so no retry happens.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:
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
| Requirement | Best 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 control | Server-side local eval with explicit stable ID |
| Quick fix, minimal code changes | Device bucketing |
| Can't change identity flow at all | Experience continuity |
Further reading
- Production-ready Feature Flags – the pure function model that explains why identity matters for flags
- Identity resolution – designing your identity strategy (start here if you haven't)
- Device bucketing – setup guide
- Bootstrapping – eliminating the async gap
- Local evaluation – server-side evaluation for explicit input control