Feature flags for rollouts and experiments
How PostHog feature flags decouple the deploy from the release, serving as one primitive behind kill switches, rollouts, and A/B experiments.
A new onboarding flow is ready to ship, but not for everyone at once. The product team wants it for 10% of new organizations first, then 50% if the numbers hold, then 100%. If conversion drops, they want it gone with one click, not a git revert. And at 100%, they want proof the new flow beat the old one.
So far, shipping a feature and turning it on have been the same act: you merge, the deploy goes out, the feature is live for everyone. The fix is one idea: the deploy is not the release. You ship the new code switched off, and the release is a separate act, a toggle in a dashboard, so rollback becomes one click instead of a code change.
The primitive that buys you this is the feature flag, and the same flag does three jobs depending on how you configure it: a kill switch to ship safely, a percentage rollout to release gradually, and an A/B experiment to measure. The one hard part is keeping the user from seeing the wrong variant flash on screen, and that’s where you’ll spend the most time. Flags hang off the same distinctId identity from the previous lesson, so once a flag is evaluated, every event you fire records which variant the user saw, at no extra cost.
A flag is a decision you can change without a deploy
Section titled “A flag is a decision you can change without a deploy”A feature flag is a named decision. Your code asks PostHog “what’s the value of new_onboarding for this user?” and PostHog answers with true, or 'variant_a', or a config object. The rules that decide the answer live in the dashboard, not your code, which only reads the value.
That split is the whole point. The code path is already deployed, so changing who sees the feature is a dashboard edit that propagates in seconds. Without a flag, rollback means a revert, a push, CI, and a deploy under incident pressure; with a flag, you open PostHog and set it off. The code shipped once; the decision moves freely on top of it.
A flag returns one of three value shapes, reached for in order:
- Boolean: on or off, per user. The default, and what you’ll use most of the time. Kill switches and rollouts are booleans.
- Multivariate : a named string variant like
control,variant_a, orvariant_b. Reach for it when an experiment needs more than two arms. - JSON payload: a structured config object, shipped per group of users. Reach for it when the flag governs configuration rather than a single branch.
Each shape has its own hook; a later section covers the imports.
const showNewOnboarding = useFeatureFlagEnabled('new_onboarding'); // booleanconst variant = useFeatureFlagVariantKey('paywall_copy_test'); // 'control' | 'variant_a' | …const pricing = useFeatureFlagPayload('eu_pricing'); // { price_cents: 2900, currency: 'EUR' }A payload flag is worth picturing: to ship different pricing to a cohort of EU users, the flag returns the whole config at once instead of three tangled booleans.
{ "price_cents": 2900, "currency": "EUR" }Every user runs the same useFeatureFlagPayload('eu_pricing'), and PostHog decides which payload each gets. That is the boundary that makes flags safe to change: the code reads a value, never a rule.
Release conditions: targeting decided in the dashboard, not in code
Section titled “Release conditions: targeting decided in the dashboard, not in code”PostHog calls the targeting rules release conditions. You can target on:
- Everyone: the flag is on for all users.
- A user property:
plan = 'pro'. Only matching users see the feature. - A group property:
organization.seats > 10, using the org-level group analytics from the previous lesson. The whole org gets the feature or none of it does. - A percentage: a deterministic 10% of users, which is how a rollout ramps.
- A cohort: any PostHog cohort, itself a saved property predicate.
Combine these with AND / OR for precise audiences (“pro plan and more than 10 seats”, or “EU country or in the beta cohort”).
The percentage option hides a correctness trap. “10% of users” isn’t a coin flip per request: PostHog hashes the user’s distinctId and checks whether it falls in the bottom 10% of the range. The hash is deterministic, so the same user lands in the same bucket on every visit. This is why you never roll your own percentage logic.
const showNew = Math.random() < 0.1;Re-buckets on every render and every visit. The user sees the new flow, refreshes, and sees the old one. It ruins rollouts, where content jumps, and breaks experiments, where the same user lands in both arms.
const showNew = useFeatureFlagEnabled('new_onboarding');Stable per user. PostHog hashes the distinctId, so the same user gets the same answer every visit. The 10% holds steady, which is what a rollout and an experiment require.
new_onboarding Boolean Roll out the redesigned onboarding flow.
of matching users, bucketed by a deterministic hash of their
distinctId.
Whether the flag is on for everyone, 10% of pro orgs, or a single cohort, useFeatureFlagEnabled('new_onboarding') stays the same line. That is the contrast: a rich rules panel on one side, an unchanging one-liner on the other. The rules move; the read stays put.
Target on bounded, low-cardinality, non-sensitive facts like plan, seats, created_at, and country, never on something like email. Targeting predicates are no place for personal data, and high-cardinality matching makes the rule slow and brittle.
The flash of the default variant
Section titled “The flash of the default variant”Reading a flag purely on the client ships a bug most beginners never notice. The SDK loads in the browser, asks PostHog for the variant, and renders. Walk one page load:
- The page paints. The SDK hasn’t loaded, so the flag read returns the default (or
undefined), and the user sees the control variant. - The SDK initializes and fires a network request to PostHog for this user’s flags.
- The response arrives, and the component re-renders to the assigned variant.
For one paint the user saw the wrong thing, then it flickered to the right thing. That flicker is the flash of the default variant, and its cost depends on the flag’s job. For a rollout it’s a visible UX bug: content jumps, layout shifts, the page looks broken for a beat. For an experiment it poisons your data: the user was exposed to control, then to the variant, so the first events can fire while they’re still bucketed under the wrong arm. Day-one numbers are garbage, and nothing in the dashboard warns you.
Scrub the sequence below: the first three steps are the broken client-only path, the last is the fix you’ll build next.
The fix isn’t a faster network request. It’s moving the flag evaluation before the first paint, so the client’s first render already knows the answer: server-side bootstrap, the next section.
The flip in step 3 happens at hydration : when the client takes over the server’s HTML, any value the two sides disagree on can flip at that handoff. It returns as a subtle trap in the next section.
Bootstrap flags from the server
Section titled “Bootstrap flags from the server”Evaluate the flag on the server while the page renders, then hand the answer to the client so the first render already has it. PostHog’s client SDK has a bootstrap option for exactly this: you evaluate flags on the server with posthog-node, then pass the resulting { flagKey: value } map into posthog.init(). The SDK starts up with the values already populated, so useFeatureFlagVariantKey('new_onboarding') returns the real answer on the first render, no round-trip and no flicker.
Build this in three layers: get the value to the client, make both evaluations agree on identity, then keep the server evaluation fast.
Layer one: get the value to the client
Section titled “Layer one: get the value to the client”Evaluate once per request, in the root layout or a server boundary wrapping your client provider. Read the user’s distinctId, evaluate flags against it, and thread the resolved map into the 'use client' provider, which passes it to posthog.init.
// app/layout.tsx — Server Componentexport default async function RootLayout({ children }: { children: ReactNode }) { const distinctId = await getDistinctId(); const flags = await posthog.evaluateFlags(distinctId); return ( <PostHogProvider distinctId={distinctId} bootstrapFlags={flags.featureFlags}> {children} </PostHogProvider> );}
// app/_components/posthog-provider.tsx — 'use client'posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', bootstrap: { distinctID: distinctId, isIdentifiedID: Boolean(userId), featureFlags: bootstrapFlags, },});Read the same identity cookie the rest of the app uses. This is the distinctId both evaluations must agree on.
// app/layout.tsx — Server Componentexport default async function RootLayout({ children }: { children: ReactNode }) { const distinctId = await getDistinctId(); const flags = await posthog.evaluateFlags(distinctId); return ( <PostHogProvider distinctId={distinctId} bootstrapFlags={flags.featureFlags}> {children} </PostHogProvider> );}
// app/_components/posthog-provider.tsx — 'use client'posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', bootstrap: { distinctID: distinctId, isIdentifiedID: Boolean(userId), featureFlags: bootstrapFlags, },});Evaluate every flag for this user once, through the lib/posthog.ts server adapter. Its .featureFlags is the resolved { 'flag-key': true | 'variant' } map.
// app/layout.tsx — Server Componentexport default async function RootLayout({ children }: { children: ReactNode }) { const distinctId = await getDistinctId(); const flags = await posthog.evaluateFlags(distinctId); return ( <PostHogProvider distinctId={distinctId} bootstrapFlags={flags.featureFlags}> {children} </PostHogProvider> );}
// app/_components/posthog-provider.tsx — 'use client'posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', bootstrap: { distinctID: distinctId, isIdentifiedID: Boolean(userId), featureFlags: bootstrapFlags, },});Thread the map and the distinctId into the 'use client' provider as props. The browser hasn’t touched the network yet.
// app/layout.tsx — Server Componentexport default async function RootLayout({ children }: { children: ReactNode }) { const distinctId = await getDistinctId(); const flags = await posthog.evaluateFlags(distinctId); return ( <PostHogProvider distinctId={distinctId} bootstrapFlags={flags.featureFlags}> {children} </PostHogProvider> );}
// app/_components/posthog-provider.tsx — 'use client'posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: '/ingest', bootstrap: { distinctID: distinctId, isIdentifiedID: Boolean(userId), featureFlags: bootstrapFlags, },});The client init receives the map as bootstrap, so the first render already has the real values, no round-trip and no flicker.
The bootstrap shape has a few exact keys, easy to get wrong: distinctID (capital ID), isIdentifiedID (a boolean, true once the user is identified), and featureFlags (the { 'flag-key': true | 'variant' } map). There is no featureFlagPayloads key; JSON payloads aren’t bootstrapped through this option.
PostHog’s @posthog/next package will fold this wiring into one line through its bootstrapFlags helper once it stabilizes, but it isn’t the default yet, so you wire posthog-node by hand here.
Layer two: one identity, evaluated twice, same answer
Section titled “Layer two: one identity, evaluated twice, same answer”The flag is now evaluated twice: on the server to bootstrap, and on the client when the SDK runs. If they use different distinctIds, they can produce different variants, and the UI flips at hydration, the flash you set out to kill, plus a split bucket where the user counts as two people. The fix is that both sides read the same identity: the distinctId cookie from the request boundary feeds the server evaluation and the client SDK alike.
distinctId cookie
distinct_id=u_8f3a… distinctId in → same variant out → no flip at hydration
One wrinkle, handled in the previous lesson: once the user signs in and you call identify(), their known user id supersedes the anonymous cookie id. Both sides still resolve to the same identity, now the real id rather than the cookie.
Layer three: why server evaluation doesn’t tank latency
Section titled “Layer three: why server evaluation doesn’t tank latency”Evaluating flags on the server every request sounds like a network hop to PostHog on every render, and naively it would be. The escape is local evaluation: posthog-node downloads the full flag configuration once, then evaluates in memory and refreshes the rules on an interval. A server-side read becomes an in-process hash computation, not a round-trip. To turn it on, give the server client a key that can read flag definitions and set the refresh cadence:
import 'server-only';import { PostHog } from 'posthog-node';
import { env } from '@/env';
export const posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { host: env.NEXT_PUBLIC_POSTHOG_HOST, personalApiKey: env.POSTHOG_PERSONAL_API_KEY, featureFlagsPollingInterval: 30_000,});personalApiKey unlocks local evaluation: it lets the server fetch flag definitions, not just send events. (PostHog now recommends a dedicated feature-flags secure key; the personal API key still works.) featureFlagsPollingInterval sets the refresh cadence, here every 30 seconds. Now server-side flag reads are cheap.
Reading flags: hooks on the client, one call on the server
Section titled “Reading flags: hooks on the client, one call on the server”With bootstrap guaranteeing correct values, the read is the easy part, and there are two boundaries you read from. The React hooks live in a companion package, @posthog/react; the provider, identify, and track plumbing stay as you built them, and the hooks talk to the same client instance.
On the client, you have one hook per value shape:
import { useFeatureFlagEnabled, useFeatureFlagVariantKey, useFeatureFlagPayload,} from '@posthog/react';
const showNewOnboarding = useFeatureFlagEnabled('new_onboarding');const variant = useFeatureFlagVariantKey('paywall_copy_test');const pricing = useFeatureFlagPayload('eu_pricing');They return the real value on the first render, never undefined mid-flicker, and they re-render when PostHog updates the flag remotely: bump a rollout from 10% to 50% and live clients pick it up without a redeploy.
On the server, you read from a snapshot: call posthog-node’s evaluateFlags(distinctId), then pull flags off it with .getFlag('new_onboarding') or .isEnabled('new_onboarding'). Use this in a server component, or in proxy.ts when the flag controls a redirect or a layout swap.
Both reads return the same decision at different boundaries:
'use client';
export const OnboardingCard = () => { const showNew = useFeatureFlagEnabled('new_onboarding'); return showNew ? <NewOnboarding /> : <LegacyOnboarding />;};A hook read at the component top level. Bootstrap populated the value, so showNew is correct on the first render, with no flicker.
import { posthog } from '@/lib/posthog';
export const Dashboard = async () => { const distinctId = await getDistinctId(); const flags = await posthog.evaluateFlags(distinctId); const showNew = flags.isEnabled('new_onboarding'); return showNew ? <NewOnboarding /> : <LegacyOnboarding />;};The same decision, read in-process on the server. No client round-trip, and the HTML ships already forked.
Here is the payoff. Once a flag is evaluated for a user, PostHog attaches a super-property named $feature/<flag> to every event they fire afterward:
{ "event": "plan_upgraded", "properties": { "from_plan": "free", "to_plan": "pro", "$feature/paywall_copy_test": "variant_a" }}That $feature/paywall_copy_test: 'variant_a' is what makes experiments and funnels work: the event store now knows which variant the user was on when they upgraded, and you do nothing extra for it.
Now write one. The exercise builds a component that reads a flag and forks the render, with a mocked useFlag() hook so you can focus on the conditional render.
useFlag('new_onboarding') returns the assigned variant. Complete OnboardingFork so it returns the right card for the value it's handed: 'control' → the element with data-testid="legacy-onboarding", 'variant_a' → data-testid="new-onboarding", and undefined (still loading) → data-testid="onboarding-loading". App reads the flag and passes it down — don't change the mock; the tests render OnboardingFork with each value directly.
Reference solution
A flat switch (or an if/else chain) on the variant value, one branch per shape, with the undefined case as the neutral default. Nothing here is flag-specific: a flag read is just a value, and forking on it is a plain conditional.
export function OnboardingFork({ variant }: { variant: Variant }) { switch (variant) { case 'variant_a': return <div data-testid="new-onboarding">Welcome — let's get you set up</div>; case 'control': return <div data-testid="legacy-onboarding">Getting started</div>; default: return <div data-testid="onboarding-loading">Loading…</div>; }}The default branch covers undefined, what the hook returns before the value resolves. Bootstrap closes that window, but handling it keeps the component honest if a flag ever arrives un-bootstrapped.
A flag is a value, and reading it is a normal React conditional; the targeting, percentages, and experiment all happen in the dashboard.
Kill switch, rollout, experiment: one primitive, three uses
Section titled “Kill switch, rollout, experiment: one primitive, three uses”A kill switch, a rollout, and an experiment are not three tools. They’re one flag primitive used three ways, differing in value shape, discipline, and lifespan.
- Kill switch: a boolean, default off. Gate every non-trivial feature behind one for its first week. If it breaks, flip it off instantly, with no deploy or rollback. Lifespan: weeks. Delete it once the feature is stable.
- Rollout: a boolean, ramped by percentage (10%, then 50%, then 100%) on the deterministic
distinctIdhash. Each bump is a dashboard edit; watch the metrics between bumps. Lifespan: weeks. Delete it at 100%. - Experiment: a multivariate flag, typically a 50/50 split, with a metric attached. It is the only one of the three that exists to measure rather than to release. Lifespan: two to four weeks. When you have significance, convert the winner to a rollout and delete the losing branch.
They’re the same API; the skill is picking the right one, and the order you ask the questions in does that work. Walk it here:
A multivariate flag, ~50/50 split, with a primary metric attached before launch. Lives 2–4 weeks. Exit: convert the winner to a rollout, delete the loser.
A boolean, default off, ramped to 100% once stable. The point is the instant off-toggle for incident response. Lives weeks. Exit: delete after the feature stabilizes.
A boolean, percentage ramp (10% → 50% → 100%) on the deterministic distinctId hash.
Watch metrics between bumps. Lives weeks. Exit: delete at 100%.
Ask measuring? first, then all-at-once or ramp?; the decision lives in that order. Now sort real situations into the three patterns:
Sort each scenario into the flag pattern it calls for. Drag each item into the bucket it belongs to, then press Check.
Experiments: a flag plus a metric, run with discipline
Section titled “Experiments: a flag plus a metric, run with discipline”An experiment is where juniors lose money, not through bad code but through bad process. The code is trivial: it’s a multivariate flag read, the same useFeatureFlagVariantKey('paywall_copy_test') you know, with a primary metric attached, say plan_upgraded firing within 14 days of paywall_viewed. PostHog runs the statistics; this lesson teaches the discipline that makes them trustworthy, not the math.
Three rules separate a trustworthy result from theater:
Pre-declare the primary metric and the hypothesis before you launch. Write the hypothesis into the experiment description: “the three-step onboarding will lift trial-to-paid by at least 2 points.” A metric you pick after seeing the data is unfalsifiable, because you’ll always find some number that moved.
The primary metric must be a PostHog event. Only an event carries the $feature/<flag> tag that lets PostHog join the metric to the variant. A number from your billing dashboard or a spreadsheet has no such tag, so PostHog can’t attribute it to an arm.
Don’t stop on the first green day. This is the expensive one.
And recall the silent failure from earlier: if bootstrap hasn’t fixed the flash of the default variant, your day-one buckets are poisoned before any of this discipline matters. Bootstrap first, then run clean.
Put the failure modes together. The following question describes a broken experiment; pick everything that could have caused it:
An experiment “proved” a winner, but the lift evaporated the moment the variant shipped to 100% — and a whole slice of exposed users never showed up in the results at all. Which of these would explain that? Select all that apply.
$feature_flag_called exposure, so those users can’t be attributed and simply vanish from the results; and a metric that isn’t a PostHog event has no $feature/<flag> tag to join on. The odd one out is the split — a 50/50 allocation is exactly what you want for an experiment, not a cause of failure.Deleting a flag after it reaches 100%
Section titled “Deleting a flag after it reaches 100%”Every flag is a fork: if (flag) { ... } else { ... }. The moment a flag reaches 100%, the losing branch becomes dead code that no one runs or tests, and that confuses the next reader. So deletion is the last step of a rollout, not optional housekeeping; PostHog’s “last evaluated” and activity views surface the candidates, flags whose branches have collapsed onto a single variant.
The deletion order is load-bearing. Delete the flag in PostHog while deployed code still reads it, and that live code asks for a flag that no longer exists. So remove the read first, ship it, and only then delete the flag.
-
Grep the flag name across the codebase. Remove the
if (flag)fork, keeping the winning branch as now-unconditional code. Open the PR. -
Merge and deploy. No running version of the code reads the flag anymore.
-
Only now, delete the flag in PostHog.
-
Confirm zero references remain: search the repo, and check that PostHog shows no recent evaluations.
Never swap steps 2 and 3: delete-then-deploy leaves a window where live code reads a flag that’s already gone. Make this a quarterly habit, running every collapsed flag through the steps above, so “add a flag” closes its loop instead of accumulating dead forks.
External resources
Section titled “External resources”The PostHog docs are the place to confirm exact method names and to dig into the experiment statistics this lesson skipped. Pete Hodgson’s essay is the canonical map of the territory.
Targeting, rollouts, payloads, and the bootstrap option in full.
Primary and secondary metrics, significance, and how PostHog computes the readout.
Pete Hodgson's definitive essay — release vs. experiment vs. ops toggles, and their carrying cost.
The exact bootstrap shape that kills the flash of the default variant.