Skip to content
Chapter 93Lesson 5

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, or variant_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'); // boolean
const 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.

The release-conditions panel for the `new_onboarding` flag, set to a 25% rollout. Bumping it to 50% is an edit here, not a deploy, and the read in your code never changes.

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.

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:

  1. The page paints. The SDK hasn’t loaded, so the flag read returns the default (or undefined), and the user sees the control variant.
  2. The SDK initializes and fires a network request to PostHog for this user’s flags.
  3. 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.

app.acme.com/onboarding WRONG
posthog-js not loaded yet — flag read returns the default
new_onboarding · control Get started The classic single-screen form.
Client-only, first paint. The SDK hasn't loaded, so the flag read returns the default and the user sees the OLD onboarding card.
app.acme.com/onboarding WRONG
SDK initialized — asking PostHog for this user's flags…
new_onboarding · control Get started The classic single-screen form.
Client-only. The SDK loads and requests this user's flags from PostHog. The old card stays on screen while the request is in flight.
app.acme.com/onboarding
Response arrived — re-render flips control → variant_a
new_onboarding · variant_a Welcome — let's get you set up A guided, 3-step onboarding.
Client-only. The response arrives and the component re-renders, jumping the card from old to new. That visible jump is the flash, and for an experiment the first events already fired under the wrong variant.
app.acme.com/onboarding CORRECT
First paint already has the bootstrapped variant — no round-trip
new_onboarding · variant_a Welcome — let's get you set up A guided, 3-step onboarding.
Bootstrapped. The assigned variant is baked into the first render: no network request on the critical path, no jump. This single correct paint collapses steps 1–3.

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.

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.

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 Component
export 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 Component
export 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 Component
export 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 Component
export 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.

1 / 1

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.

posthog-node server evaluation bootstrap, before first paint
posthog-js client SDK on hydration, in the browser
same distinctId in → same variant out → no flip at hydration
One identity feeds both evaluators. The server `posthog-node` evaluation and the client `posthog-js` SDK read the same `distinctId`, compute the same variant, and so don't 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:

lib/posthog.ts
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.

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.

Preview
    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 distinctId hash. 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:

    Pick the flag pattern

    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.

    Kill switch Boolean, default off, instant off-toggle
    Rollout Boolean, percentage ramp
    Experiment Multivariate, metric attached
    Shipping a risky payments rewrite and wanting to switch it off the second it misbehaves
    Wrapping a fragile new export feature so on-call can disable it without a deploy
    Releasing a redesigned dashboard to 20% of orgs before everyone
    Gradually exposing a new search backend, watching error rates as you ramp
    Testing two paywall headlines to see which lifts trial-to-paid
    Comparing a one-step vs three-step signup to prove which converts better

    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.

    The team called it the first afternoon the dashboard flashed “significant,” instead of waiting out the run.
    Nobody wrote down what they were testing until after the data was in, and then they picked whichever number had moved.
    The variant was decided in the browser, so the first paint rendered control before the SDK answered.
    The card only pulled the flag’s payload object and rendered from that — it never touched the boolean or variant hook.
    The two arms each got half the traffic instead of a lopsided 90/10 split.
    ”Conversion” was read off the billing provider’s revenue export rather than tracked as an event.

    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.

    1. Grep the flag name across the codebase. Remove the if (flag) fork, keeping the winning branch as now-unconditional code. Open the PR.

    2. Merge and deploy. No running version of the code reads the flag anymore.

    3. Only now, delete the flag in PostHog.

    4. 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.

    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.