Skip to content

Feature flags without the flicker

Client-side flags render the control experience first and swap it a beat later. Evaluate on the server, pass the decision down, and keep a bootstrap for the client hooks.

PostHog4 min readships at docs/solutions/posthog/feature-flags-without-flicker.md

Tags: posthog · feature-flags · nextjs · ssr · experiments · performance

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:

  1. HTML arrives and renders: the flag is unknown, so your code takes the fallback branch (the control).
  2. The SDK loads.
  3. The SDK requests flag values for this distinct id.
  4. 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:

  1. Delete the branch that will never run, not just the condition.
  2. Remove the flag from the bootstrap object.
  3. 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_called per person per variant, not a stream of alternating values.