You ship a flagged redesign of the pricing page. It works. But every visitor sees the old hero for a few hundred milliseconds, then it snaps to the new one. On a slow connection it is a full second. Your experiment results come back noisy, someone files "the page flashes", and a CLS regression shows up in your Core Web Vitals.
Nothing is broken. This is what client-side flag evaluation looks like.
Why the flicker exists
The browser SDK cannot know a flag's value until it has asked. The sequence is:
- HTML arrives and renders: the flag is unknown, so your code takes the fallback branch (the control).
- The SDK loads.
- The SDK requests flag values for this distinct id.
- The response arrives, the hook re-renders, the variant appears.
Steps 2-4 are a network round trip, often behind other work on the main thread. Anything rendered from a flag in that window is wrong, and the user watches it being corrected.
The naive fix (hide the content until flags load) trades a flicker for a blank region, which is worse for both perceived speed and layout stability.
The right way: decide on the server
The server can evaluate the flag before a single byte of HTML is written. Then the page renders once, correctly.
// src/app/(marketing)/pricing/page.tsx
import { cookies } from "next/headers";
import { serverFeatureFlag } from "@/lib/analytics/posthog-server";
import { PricingV1 } from "@/components/pricing-v1";
import { PricingV2 } from "@/components/pricing-v2";
export default async function PricingPage() {
const distinctId = await stableDistinctId();
const variant = await serverFeatureFlag("pricing-redesign", distinctId);
return variant === "test" ? <PricingV2 /> : <PricingV1 />;
}
serverFeatureFlag wraps posthog-node's evaluateFlags and, importantly,
never throws:
export async function serverFeatureFlag(
flag: string,
distinctId: string,
options?: { groups?: Record<string, string> },
): Promise<boolean | string | undefined> {
const posthog = client();
if (!posthog) return undefined;
try {
// posthog-node 5: evaluateFlags replaces the deprecated getFeatureFlag.
const flags = await posthog.evaluateFlags(distinctId, {
flagKeys: [flag],
groups: options?.groups,
});
return flags.getFlag(flag);
} catch (error) {
console.warn(`[analytics] flag "${flag}" could not be evaluated:`, error);
return undefined; // fall back to control; a flag service outage is not a page outage
}
}
The part everyone gets wrong: a stable distinct id on the server
A flag's value is a hash of the flag key and the distinct id. Generate a fresh id per request and every visitor gets a fresh coin flip: the same person sees the variant, then the control, then the variant. Experiment results become meaningless, and worse, they look plausible.
For a signed-in user, use the database id you already identify with. For an anonymous visitor, read PostHog's own cookie, which the browser SDK sets and which persists across requests:
// src/lib/analytics/distinct-id.ts
import { cookies } from "next/headers";
export async function stableDistinctId(): Promise<string> {
const jar = await cookies();
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY ?? "";
const raw = jar.get(`ph_${key}_posthog`)?.value;
if (raw) {
try {
const parsed = JSON.parse(decodeURIComponent(raw)) as { distinct_id?: string };
if (parsed.distinct_id) return parsed.distinct_id;
} catch {
// Malformed cookie: fall through to the anonymous bucket.
}
}
// First visit, before the SDK has ever run. Everyone in this state shares one
// bucket, which is honest: they are not yet a tracked person.
return "anonymous";
}
Signed-in pages should skip that entirely and pass user.id, which is stable by
construction.
Local evaluation, so a flag is not a network call per render
Every evaluateFlags call is an HTTP request to PostHog unless the SDK can
evaluate locally. With a personal API key, posthog-node downloads flag
definitions and evaluates in-process:
new PostHog(key, {
host,
personalApiKey: process.env.POSTHOG_API_KEY,
featureFlagsPollingInterval: 60_000,
});
Two caveats worth knowing before you enable it. Local evaluation cannot resolve flags that depend on person properties the server does not have, so pass them explicitly:
const flags = await posthog.evaluateFlags(distinctId, {
flagKeys: ["pricing-redesign"],
personProperties: { plan: user.plan },
onlyEvaluateLocally: false, // allow a remote call when local cannot decide
});
flags.getFlag("pricing-redesign");
And a personal API key is powerful: scope it read-only, keep it server-side,
and never let it near NEXT_PUBLIC_.
Keeping the client hooks flicker-free too
Server evaluation solves the first paint. Interactive components that use
useFeatureFlagEnabled() still start from nothing. Bootstrap the browser SDK
with the values you already resolved on the server:
// server component
const flags = {
"pricing-redesign": (await serverFeatureFlag("pricing-redesign", distinctId)) ?? false,
};
return <AnalyticsProvider bootstrapFlags={flags} distinctId={distinctId}>{children}</AnalyticsProvider>;
// inside the provider, at init
posthog.init(key, {
api_host: "/ingest",
bootstrap: { distinctID: distinctId, featureFlags: bootstrapFlags },
});
The hooks now return the right value on their very first render, and the SDK refreshes them in the background.
Rendering, caching and flags
A page that reads a flag per visitor cannot be a static page. Either accept that it renders dynamically, or move the decision to the edge and vary the cache key on the bucket, never leave it static and hope. A cached HTML response containing one variant served to everyone is the failure mode that makes an experiment report a clean, confident, completely fabricated result.
If the flagged surface is small, the cheapest correct answer is often to keep
the page static and render only the flagged component dynamically inside a
<Suspense> boundary.
Cleaning up
A flag that has been at 100% for a month is not a flag, it is a dead branch and a permanent network call. When you remove one:
- Delete the branch that will never run, not just the condition.
- Remove the flag from the bootstrap object.
- Archive the flag in PostHog rather than deleting it, so historical experiment data keeps its meaning.
Verifying
- Disable JavaScript and load the page. You should get the correct variant in the HTML: proof the decision happened on the server.
- Reload ten times. The variant must not change. If it does, your distinct id is not stable.
- Throttle to Slow 3G and watch the first paint. No swap, no blank region.
- Check your experiment's exposure events: one
$feature_flag_calledper person per variant, not a stream of alternating values.