Skill 77 · Instrument Feature Flags
Subchapter 77.4
references/best-practices.mdMarkdown20 KBView on GitHub
AI agents: this is one page from PostHog’s docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt (opens in a new tab)
identify() before evaluating flags – the hash uses the wrong ID otherwise. This is the most common input problem.undefined explicitly – it means “not evaluated yet,” not false.The mental model: Flags are pure functions – same flag key + same distinct ID = same result. Always. Unexpected results are almost always input problems – if the result changed, an input changed.
A flag hashes two things – the flag key and the distinct ID – and returns a deterministic result. Same inputs, same output. Every time.
hash("my-experiment", "user-123") → 0.31 → always 0.31On top of that, PostHog layers property targeting (does this user match?), rollout percentage (is their position below the threshold?), and variant assignment. But the foundation is the hash: same flag key + same distinct ID = same result.
Technically
“Pure function” means deterministic given a stable flag definition. The definition (rollout %, targeting rules, variants) is external state. Given the same definition, evaluation is fully deterministic on flag_key + distinct_id. Some features like experience continuity add persistence layers that introduce side effects on the server, but from your perspective as the caller, the model holds: same inputs, same output.
PostHog uses SHA-1:
hash_key = "{flag_key}.{distinct_id}"
position = parseInt(sha1(hash_key).slice(0, 15), 16) / LONG_SCALE → float in [0, 1]
in_rollout = position <= rollout_percentage / 100For variants, a second hash with salt "variant" maps to variant ranges independently. The flag key is included so the same user gets independent assignments across different flags.
If the flag has property targeting, PostHog first checks whether the person matches the conditions. If they don’t match, the hash never runs – the flag returns false.
If you evaluate the same flag with the same distinct ID a million times, you will get the same result a million times. It’s how the math works. The hash is deterministic. It doesn’t drift, it doesn’t have off days, and it doesn’t return different values on Tuesdays.
So when a flag returns something you didn’t expect, the flag is fine, the problem is in the inputs passed to the flag. Something about the identity, the properties, or the flag definition wasn’t what you assumed. Find what changed, and you’ve found the problem.
If you keep running into flag issues and they’re not incidents (opens in a new tab), the conversation isn’t about PostHog’s flag behavior – it’s about how your application coordinates the data that flags depend on. That’s an engineering conversation about identity flows, property syncing, and evaluation architecture. No single config tweak fixes it.
We’re here to help with that – this guide, PostHog AI (opens in a new tab), and professional services (opens in a new tab) all exist for exactly this. But the starting point is always the same: look at the inputs.
When something goes wrong, in order of likelihood:
undefined treated as false, no handling for the loading gap, evaluating repeatedly instead of recording the result.Identity is the most common input problem. The hash takes two inputs: the flag key (stable) and the distinct ID (your responsibility). If the distinct ID is wrong at the moment of evaluation, the hash produces a valid but incorrect result. The flag is working perfectly – it just answered a question about the wrong person.
If you call identify() after a flag has already been evaluated, the flag likely used the anonymous ID. The hash produced one result. After identify(), the distinct ID changes, the hash changes, and the next evaluation returns a different variant. You see a “flip” – but it’s because the input changed.
Call identify() (opens in a new tab) before any flag evaluation in auth flows. If you can’t guarantee that timing, bootstrap (opens in a new tab) with the stable ID at init so the distinct ID is correct from the first millisecond. See keeping flag evaluations stable (opens in a new tab) for the full picture.
SPA-specific timing. In single-page applications, identify() and event captures often fire from different components during the same navigation in unpredictable order. The SDK updates the distinct_id synchronously when identify() runs, but if capture() was called first in the same execution frame, that event uses the anonymous ID. The fix: call identify() before the navigation that mounts post-auth components – in Vue, in beforeEach before next(); in React, before navigate(), not in a useEffect inside the target route.
If you’ve enabled experience continuity (opens in a new tab) (flag persistence across authentication), consider what that’s telling you: the distinct ID is changing during your session, and you need PostHog to paper over it.
That comes at a cost. Experience continuity couples flag evaluation with database writes – every evaluation reads and writes to the DB to persist the result. This mixes two concerns (evaluation and storage) that should be separate, and it’s the source of known bugs (opens in a new tab) where values can still change after identify(). It also means no support for local evaluation (opens in a new tab) and slower flag responses.
The better fix is to make persistence unnecessary. Use device bucketing (opens in a new tab) for single-device consistency, or design your identity flow so the distinct ID never changes (opens in a new tab). If you need experience continuity today, treat it as a migration path toward proper identity resolution (opens in a new tab), not a permanent solution. The identity gap it papers over is the root cause of the most common flag issues – closing that gap eliminates the need for persistence entirely.
How you evaluate flags – where, when, and how often – determines the complexity of your implementation. Most workarounds exist because the evaluation happens in the wrong place or at the wrong time.
A flag is a one-time signal, not a continuous dependency. Evaluate it once, record the result, serve from that recording. Re-evaluate only when something meaningful changes.
Re-evaluating on every request creates cost, latency, and the conditions for “flipping” – you’re giving the system repeated chances to return a different answer when inputs shift. That’s not a bug. That’s the pure function doing its job with different inputs.
If you target a flag on plan_type: "pro", your app originally told PostHog this person is Pro. Evaluate the flag from the same place that has that knowledge – your server. PostHog does the distribution math; your app provides the targeting data.
If you evaluate client-side instead, the SDK needs to fetch that property from PostHog’s servers – a round-trip to look up what you originally sent it. Any flag check before that completes evaluates against incomplete data.
If you must evaluate client-side, use setPersonPropertiesForFlags() (opens in a new tab) to set properties locally before evaluation. This avoids the round-trip when you already have the data in the browser.
Property targeting is fine – just understand that the further the evaluation is from the data, the more async complexity you take on.
Server-side local evaluation (opens in a new tab) is where the pure function model is fully legible:
setPersonPropertiesForFlags(), onFeatureFlags(), and bootstrap to bridge the gap between where the data lives and where the flag evaluates. Server-side eliminates the gap.Client-side evaluation is right when you need properties only available in the browser, real-time flag changes, or have no server. But you’re trading explicit inputs for implicit ones, and every workaround bridges that gap.
Client-side flag evaluation is async – the SDK needs to fetch values from PostHog. Any flag check before that completes returns undefined, not false.
Bootstrap (opens in a new tab) is the fix. Evaluate flags server-side and pass values to the client at init. The value exists before the page renders – no gap, no flicker.
If you can’t bootstrap, use onFeatureFlags() to wait. This means you will need a loading state (spinner, skeleton) until flags arrive – it prevents showing the wrong variant but doesn’t prevent a delay.
posthog.getFeatureFlag() returns undefined before flags load. That means “not evaluated yet,” not “flag is off.”
JavaScript
// Returns undefined before flags load – not false
if (posthog.getFeatureFlag('my-experiment') === 'test') {
// Never runs during the loading gap
}Handle it with bootstrap (opens in a new tab) (preferred) or onFeatureFlags() (adds a loading state). You can check the current identity with posthog.get_distinct_id().
The “not loaded yet” return value varies across SDKs – some return undefined/nil/None, others return false or a defaultValue you provide. Don’t assume that a falsy return means the flag is off. Check your SDK’s documentation for the exact return type of getFeatureFlag() and isFeatureEnabled() when flags haven’t loaded, and handle that state explicitly. If your goal is to programmatically check whether a flag exists at all, use the Feature Flags API (opens in a new tab) to query flag definitions directly.
Flags are infrastructure. Like any infrastructure, they accumulate cost when left unattended. These are operational practices that keep your flag system clean and efficient.
Every flag in PostHog is configured as client-side, server-side, or both via evaluation contexts (opens in a new tab). New flags default to “server and client” – this exists for backwards compatibility (it’s how all flags worked before we added evaluation contexts) and to avoid blocking users who haven’t thought about their implementation yet. It’s a safe starting point, not a recommendation.
If all your flags are set to both, that usually means the decision was never revisited after creation – and you’re paying for client-side evaluation on flags that only need to exist on your server.
Pick the context based on where the flag is actually consumed. Server-side flags that drive backend logic don’t need client SDKs fetching and evaluating them. Client-side flags for UI variations don’t need server-side evaluation. “Both” is valid when a flag genuinely needs to be evaluated in both contexts – but it should be a deliberate choice, not the default you never changed.
A flag set to 100% of all users with no property targeting is a flag that has finished its job. It’s always returning the same value – the rollout is complete, the experiment concluded, the feature is live. If your SDK still evaluates that flag, it can keep making billable /flags requests, keep appearing in SDK payloads, and add clutter to your codebase.
Remove the flag and hardcode the winning path. If you’re not ready to remove it from code, at least archive it in PostHog so it stops being evaluated. Stale flags are the most common source of unnecessary flag evaluation. See cleaning up stale flags (opens in a new tab) for the full workflow and cutting costs (opens in a new tab) for more on reducing your bill.
An idea worth considering: design your flag code paths with an escape hatch you control outside of PostHog. For example, a “gate flag” that your server reads once every 30 seconds (not per user) – when it’s true, the feature is fully rolled out and your code skips the per-user flag evaluation entirely. This means you stop making per-user /flags requests for that rollout as soon as it’s complete, even before you remove the flag from code. And you can dial it back by setting the gate flag to false. This is also another application of “evaluate once, not continuously” – if you cache flag results, your per-user evaluation cost drops while you wait for the code cleanup.
If a flag is evaluated server-side and the result is passed to your frontend through your own application logic, the client SDK doesn’t need to evaluate it independently. But unless you explicitly disable the flag on the client, the SDK will still fetch and evaluate it – duplicating work your server already did.
This is the practical extension of “evaluate once, not continuously.” Your server evaluates, your application propagates the result, and the client consumes it as application state rather than re-asking PostHog. Disable flags in the client SDK that your server already handles to eliminate redundant evaluation and reduce payload size.
Ad blockers can disable your Feature Flags, leading to users seeing the wrong version of your app or missing a rollout. Deploy a reverse proxy (opens in a new tab) so requests go through your own domain. PostHog offers a free managed reverse proxy (opens in a new tab), or you can run your own.
The more locations a flag appears in your code, the more likely it is to cause problems – a developer removes it in one place but forgets another. If you use a flag in multiple places, wrap it in a single function:
JavaScript
function useBetaFeature() {
return posthog.isFeatureEnabled('beta-feature')
}Good naming makes flags easier to understand and maintain:
is_v2_billing_dashboard_enabled is clearer than is_dashboard_enabled.new-billing-experiment, new-billing-release.is_premium_user for a boolean, selected_theme for a string.is_premium_user instead of is_not_premium_user – avoids double negatives.Start at 5-10% of users, monitor metrics, then gradually increase. This is a phased rollout (opens in a new tab). At PostHog, we typically roll out to the developer first, then the internal team, then beta users, then everyone.
Feature flag dependencies (opens in a new tab) let one flag’s activation depend on another flag’s state – useful for enabling complex features only after foundational components are active, or running Experiments only on users with specific features enabled. Keep dependency chains simple and avoid circular dependencies.
PostHog automatically creates person properties like “Latest Current URL” and “Latest Referring Domain” — these are derived from the corresponding event properties (like $current_url) and update every time a new event comes in. If you target a flag on one of these, the flag value can change with every new event. If you need to target based on a value like this, capture it once as a stable person property (e.g., first_landing_page via $set_once) and target that instead.
Stale flags are the most common source of unnecessary cost. Beyond cleaning up flags, see our dedicated guide to cutting costs (opens in a new tab) for estimating and reducing your feature flag bill.
Ask PostHog AI
HelpfulCould be better