docs/internal/feature-flags/experience-continuity.md
Experience continuity ensures users see consistent feature flag variants when transitioning from anonymous to identified state. Without it, a user might see variant A while anonymous, then suddenly switch to variant B after logging in.
When evaluating a feature flag with multiple variants, PostHog determines which variant a user gets by hashing the distinct_id:
distinct_id (e.g., "anon_abc123" or "[email protected]")For example, with a 50/50 A/B test:
When a user logs in, their distinct_id changes, which produces a different hash value that can land in a different variant bucket:
Anonymous: hash("anon_abc123" + "my-flag") → 0.23 → Variant A
Logged in: hash("[email protected]" + "my-flag") → 0.67 → Variant B
This is the problem experience continuity solves.
When a user identifies, PostHog stores a "hash key override" that preserves the original anonymous distinct_id for future flag evaluations. This ensures the same hash bucket is used before and after identification.
Anonymous visit: distinct_id = "anon_abc123" → hash bucket 42 → variant "control"
User identifies: distinct_id = "user_456" → stored override: use "anon_abc123" for hashing
Future requests: distinct_id = "user_456" → hash bucket 42 → variant "control" (consistent!)
Hash key overrides are written during /flags requests that include $anon_distinct_id. They are not written during identify() calls.
| SDK Type | Behavior |
|---|---|
| Web SDK | Automatic. When identify() is called, the SDK automatically reloads flags with $anon_distinct_id. |
| Server SDKs | Manual. You must include $anon_distinct_id as a top-level field in /flags requests. |
A flag must have ensure_experience_continuity = true to participate in the override system. This setting is only meaningful for:
distinct_id bucketing (not device_id)Overrides are stored in posthog_featureflaghashkeyoverride:
-- Schema (simplified)
CREATE TABLE posthog_featureflaghashkeyoverride (
team_id INTEGER,
person_id INTEGER, -- FK to Person (required!)
feature_flag_key VARCHAR(400),
hash_key VARCHAR(400) -- The original anonymous distinct_id
);
1. User calls posthog.identify("user_456")
2. SDK stores previous distinct_id internally
3. SDK automatically calls /flags with:
{
"distinct_id": "user_456",
"$anon_distinct_id": "anon_abc123", // Top-level field!
...
}
4. Server writes hash key overrides for continuity-enabled flags
5. Server returns flag values using stored overrides
# You must manually include $anon_distinct_id
requests.post('https://app.posthog.com/flags/', json={
'token': 'phc_abc123',
'distinct_id': 'user_456',
'$anon_distinct_id': 'anon_abc123', # Top-level, NOT in person_properties
'person_properties': {}
})
| Component | Location |
|---|---|
| Flag evaluation entry | rust/feature-flags/src/flags/flag_matching.rs |
| Override processing | rust/feature-flags/src/flags/flag_matching_utils.rs |
| Request parsing | rust/feature-flags/src/handler/properties.rs |
| Helper methods | rust/feature-flags/src/flags/flag_operations.rs |
ensure_experience_continuity = true?hash_key instead of current distinct_id for hashingWhen writing hash key overrides, the system reads from the writer database (not the replica) to avoid replication lag issues:
// flag_matching.rs:427-434
let database_for_reading = if writing_hash_key_override {
self.router.get_persons_writer().clone()
} else {
self.router.get_persons_reader().clone()
};
Added in PR #44293
Flags at 100% rollout with no multivariate variants return the same value for everyone, making the hash bucket irrelevant. The system can skip database lookups for these flags.
OPTIMIZE_EXPERIENCE_CONTINUITY_LOOKUPS=true # Default: true
// Does flag have continuity enabled AND is eligible (person-based, distinct_id bucketing)?
flag.has_experience_continuity()
// Does the flag have variants where hashing affects assignment?
flag.has_hash_dependent_variants()
// Does any condition group have < 100% rollout?
flag.has_partial_rollout()
// Final decision: should we do the database lookup?
flag.needs_hash_key_override()
A flag doesn't need a hash key override lookup when:
Example: A flag rolled out to everyone (rollout_percentage: 100) returns true for all users regardless of their hash bucket.
flags_experience_continuity_optimized_total{status="skipped"} # Lookup was skipped
flags_experience_continuity_optimized_total{status="eligible"} # Could have been skipped (optimization disabled)
Query to identify teams that can benefit:
WITH continuity_flags AS (
SELECT
team_id,
CASE
WHEN jsonb_array_length(COALESCE(filters->'multivariate'->'variants', '[]'::jsonb)) > 0
THEN true ELSE false
END as has_variants,
CASE
WHEN EXISTS (
SELECT 1 FROM jsonb_array_elements(COALESCE(filters->'groups', '[]'::jsonb)) as g
WHERE (g->>'rollout_percentage')::numeric < 100
)
THEN true ELSE false
END as has_partial_rollout
FROM posthog_featureflag
WHERE ensure_experience_continuity = true
AND active = true
AND deleted = false
),
team_summary AS (
SELECT
team_id,
bool_and(NOT (has_variants OR has_partial_rollout)) as can_skip_lookup
FROM continuity_flags
GROUP BY team_id
)
SELECT
COUNT(*) as teams_with_continuity,
SUM(CASE WHEN can_skip_lookup THEN 1 ELSE 0 END) as teams_that_can_skip,
ROUND(100.0 * SUM(CASE WHEN can_skip_lookup THEN 1 ELSE 0 END) / COUNT(*), 1) as pct_optimizable
FROM team_summary;
Common pattern: Teams enable continuity for A/B tests, roll out to 100%, but leave the setting enabled. This optimization handles that automatically.
person_profiles: 'identified_only'Experience continuity does not work with person_profiles: 'identified_only' due to a race condition.
With identified_only, no person record exists until the $identify event is processed. But:
identify() which sends $identify event (async processing)/flags request with $anon_distinct_id (sync)/flags request arrives before the person is created$anon_distinct_idperson_profiles: 'always'With always, a person record already exists from the anonymous visit. The identify() call adds the new distinct_id to the existing person, so the hash key override write succeeds.
person_profiles: 'always' - Creates person records for anonymous users$device_id bucketing - Stable across identity changes, no person needed$anon_distinct_id when confirmedBecause experience continuity requires a person record to exist (which doesn't happen with person_profiles: 'identified_only' until after identification), we've added a new bucketing identifier: device_id to address these issues. This feature is still under construction.
Instead of using experience continuity (which requires database writes and person records), you can configure a flag to use device_id as the bucketing identifier. This is a simpler approach that works without person profiles.
When a flag is configured for device_id bucketing:
$device_id instead of distinct_id$device_id is stable across authentication state changes, users always get the same variantperson_profiles: 'identified_only' (no person record needed)Anonymous: hash("device_xyz" + "my-flag") → 42 → Variant A
Logged in: hash("device_xyz" + "my-flag") → 42 → Variant A (same!)
| Scenario | Recommendation |
|---|---|
| Anonymous user experiments (signup flows) | ✅ device_id bucketing |
Using person_profiles: 'identified_only' | ✅ device_id bucketing |
| Experiment must persist across devices | ❌ Use distinct_id + continuity |
| Already have person records (always mode) | Either works |
Set bucketing_identifier on the feature flag:
distinct_id (default) - Uses distinct_id, supports experience continuitydevice_id - Uses $device_id, no experience continuity neededThe Rust service determines the hashed identifier in flag_matching.rs:1260-1289:
// Check if flag is configured for device_id bucketing
if feature_flag.get_bucketing_identifier() == BucketingIdentifier::DeviceId {
if let Some(device_id) = &self.device_id {
if !device_id.is_empty() {
return Ok(device_id.clone());
}
}
// Falls back to distinct_id if device_id not provided
}
| Component | Status | PR/Notes |
|---|---|---|
| Rust flag evaluation | ✅ Shipped | #41281 |
| Database field | ✅ Shipped | #42463 |
| UI (behind flag) | ✅ Shipped | #43576 |
| AA test validation | ✅ Running | #44532 (signup form AA test) |
| SDK support | 🔄 Pending | SDKs need to send $device_id in /flags |
| Local evaluation (SDKs) | 🔄 Pending | SDK local eval needs bucketing_identifier support |
| Documentation | 🔄 Pending | Public docs explaining when to use each approach |
The SDK must include $device_id as a top-level field in /flags requests:
{
"token": "phc_abc123",
"distinct_id": "user_456",
"$device_id": "device_xyz789",
"person_properties": {}
}
If a flag is configured for device_id bucketing but no $device_id is provided in the request, the system falls back to using distinct_id. This maintains backward compatibility but may result in variant changes during identity transitions.
SELECT * FROM posthog_featureflaghashkeyoverride
WHERE team_id = ?
AND person_id = ?
AND feature_flag_key = ?;
SELECT key, ensure_experience_continuity, active, deleted
FROM posthog_featureflag
WHERE team_id = ?
AND ensure_experience_continuity = TRUE
AND active = TRUE
AND deleted = FALSE;
Ensure $anon_distinct_id is a top-level field, not nested in person_properties:
{
"distinct_id": "user_456",
"$anon_distinct_id": "anon_abc123",
"person_properties": {}
}
| Symptom | Likely Cause | Solution |
|---|---|---|
| Variant changes after identify | Override not written | Check request includes $anon_distinct_id at top level |
| Only some flags maintain continuity | Not all flags have setting | Enable ensure_experience_continuity on all relevant flags |
| Overrides never written (server SDK) | Manual step missing | Include $anon_distinct_id in /flags requests |
| Race condition failures | identified_only mode | Switch to always or use device_id bucketing |
/flags endpoint logs for anon_distinct_id processinghash_key_override_status (values: None, "skipped", "error", "empty", "found")flags_experience_continuity_optimized_totalflags_hash_key_query_result_total (labels: result="empty" or result="has_overrides")