Skip to main content

Command Palette

Search for a command to run...

Day 9: Feature Flags – Shipping Without Fear

Updated
11 min read
O
Frontend engineer with 6 years of experience building and shipping production web applications. Currently focused on developer experience and developer relations through hands-on analysis and public writing on developer tools, onboarding, and product messaging.

Hey_Techys!! Day 9 of the PostHog series!

You know that feeling when you build a shiny new feature, gather your team, pour resources into it, push it to production... and then watch it flop?

Or worse—it drives users AWAY?

I know a product manager who got sacked for this exact thing.

PostHog says: "I've got you covered."

With feature flags, you can release features to specific users, test them safely, and roll back instantly if things go wrong.

Let me show you what I did.

What Are Feature Flags?

Imagine this:

You build a new feature. Instead of showing it to everyone at once, you show it to:

  • 10% of users first (test the waters)

  • Then 50% (if it's working well)

  • Then 100% (full rollout)

And if something breaks? You flip a switch in PostHog and the feature disappears. No redeployment. No emergency code changes. Just... gone.

That's a feature flag.

My Feature: Inline Task Editing

If you've been following the series, you know I'm building TaskList (or TaskFlow, as I keep accidentally calling it 😅).

Current problem: Users can't edit tasks. They have to delete and recreate them.

My solution: Add inline editing. Click a task → Edit it → Save.

My fear: What if users hate it? What if it breaks something?

Feature flag to the rescue: Release it to 50% of users. See what happens. Roll back if needed.

GitHub repo: https://github.com/LizzyKate/Posthog/tree/feature-flags

Website link: https://posthog-blue.vercel.app/

Step 1: Create the Feature Flag (2 Minutes)

Go to PostHog → Feature Flags tab

(Pro tip: I couldn't find it at first. Had to search through their menu like I was hunting for treasure. My guess? PostHog is playing hard to get 🤣)

Click "New feature flag"

You'll see a form. Probably the simplest form PostHog has (finally!).

What I filled out:

Key: inline-task-editing
(This is what your code checks for. Keep it simple, lowercase, hyphenated.)

Description: "Allows user to edit task"
(Just a reminder for future-me.)

Release condition: 50% of users
(Half see it, half don't. Safe testing!)

Enable feature flag: ✅ Checked

That's it. Click Save.

2 minutes. Done.

Step 2: Build the Feature

I went to my code and built the inline editing feature:

  • Click the ⋮ menu on a task

  • Click "Edit"

  • Task becomes editable inline

  • Save or cancel

The key part: Wrapping it in a feature flag check.

const canEditTasks = posthogClient.isFeatureEnabled("inline-task-editing");
const showEdit = canEditTasks === true; // Only show when explicitly true (not undefined or false)

return (
  <DropdownMenu>
    <DropdownMenuContent>
      {/* Only show Edit if flag is enabled */}
      {showEdit && (
       <DropdownMenuItem onClick={handleStartEdit} 
         data-ph-capture-attribute="task-edit-menu-item">
          <Pencil className="mr-2 h-4 w-4" />
          Edit
        </DropdownMenuItem>
      )}
    </DropdownMenuContent>
  </DropdownMenu>
);

Notice the key: "inline-task-editing" matches what I set in PostHog. ✅

If the flag is true → Show Edit button
If the flag is false → Hide Edit button

Simple conditional rendering.

Step 3: Track Usage (So I Can See What Happens)

I didn't just implement the flag. I tracked HOW people use it.

Three events:

1. User starts editing:

  const handleStartEdit = () => {
    setIsEditing(true);
    setEditedTitle(task.title);
    setEditedDescription(task.description || "");

    // Track when user starts editing (FEATURE FLAG EVENT)
    posthog.capture("task_edit_started", {
      task_id: task.id,
      priority: task.priority,
      category: task.category,
      feature_flag: "inline-task-editing",
      has_description: !!task.description,
    });
  };

2. User saves changes:

  const handleSaveEdit = () => {
    if (editedTitle.trim()) {
      const updates: Partial<Task> = {
        title: editedTitle.trim(),
        description: editedDescription.trim() || undefined,
      };

      updateTask(task.id, updates);

      // Track successful edit (FEATURE FLAG EVENT)
      posthog.capture("task_edited", {
        task_id: task.id,
        old_title: task.title,
        new_title: editedTitle.trim(),
        title_changed: task.title !== editedTitle.trim(),
        description_changed: task.description !== editedDescription.trim(),
        feature_flag: "inline-task-editing",
      });
    }
    setIsEditing(false);
  };

3. User cancels:

  const handleCancelEdit = () => {
    setEditedTitle(task.title);
    setEditedDescription(task.description || "");
    setIsEditing(false);

    // Track edit cancellation (FEATURE FLAG EVENT)
    posthog.capture("task_edit_cancelled", {
      task_id: task.id,
      feature_flag: "inline-task-editing",
    });
  };

Why track this?

So I can answer:

  • How many people clicked Edit?

  • Do they finish editing or cancel?

  • Is this feature actually useful?

Data > guessing. ✅

Step 4: Testing (The Part That Confused Me)

I set the flag to 50% rollout.

I had 4 identified users in PostHog.

My expectation:
2 users see it, 2 users don't. Right?

What actually happened:

User 1: No Edit button ❌
User 2: No Edit button ❌
User 3: No Edit button ❌
User 4: Edit button appears! ✅

Me: "Wait... only 1 out of 4?!"

PostHog even showed: "Will match approximately 50% (2/4) of total users."

So why did only 1 user get it?

The Answer: It's Truly Random

50% rollout doesn't mean "exactly half your users."

It means each user has a 50% chance.

Think of it like flipping a coin 4 times:

  • Heads = see feature

  • Tails = don't see feature

You COULD get:

  • 2 heads, 2 tails (perfect 50/50)

  • 3 tails, 1 head (what I got)

  • 4 tails, 0 heads (bad luck!)

PostHog uses deterministic hashing:

  • Takes your user's distinct_id

  • Hashes it to a number (0-100%)

  • Compares it to the rollout percentage

  • Same user = always same result

Why this matters:

User A always sees the feature (or always doesn't)
They don't flip-flop between sessions

This creates a consistent experience. ✅

Testing Both States

To see both the "Edit button visible" and "Edit button hidden" states, I had to:

Option 1: Create more users
Keep signing up with different emails until I got both states.

Option 2: Use incognito mode
Different browser = different distinct_id = different flag value

Option 3: Change rollout to 100%
Everyone sees it. Easy testing.

I eventually got one user with true. Finally! 🎉

What they saw:

  • Click ⋮ menu

  • "Edit" option appears

  • Click it

  • Task becomes editable

  • Save or cancel

What the other 3 users saw:

  • Click ⋮ menu

  • Only "Delete" option

  • No Edit button at all

Same app. Two different experiences.

This is how you safely ship features. ✅

N/B: For email targeting, users must be identified (Day 7 covered this). But percentage rollout works for both anonymous and identified users - PostHog assigns anonymous users a distinct_id automatically.

The "Persist flag across authentication steps" checkbox helps anonymous users keep the same flag value after they log in (so they don't suddenly see a different experience).

Targeting Specific Users (And The Problem I Hit)

After testing random rollout, I thought:

"What if I want to show this to SPECIFIC users who fulfill certain conditions?"

Conditions like:

  • Regular users of my app

  • Users subscribed to a certain plan

  • Users that use a certain browser

  • Users with a certain type of device

I went back to PostHog → Edit flag → Release conditions → Add condition

I saw options:

  • Email address

  • Browser

  • Device type

  • Country

  • Custom properties, etc.

I picked Email address and entered: onyenekwelizabeth@gmail.com

Saved it.

Logged in as that user.

Result: No Edit button. 🤔

The Real Problem: undefined

I opened the console and saw:

Inline Task Editing Enabled: undefined

Not false. Not true. undefined.

This happened EVERY TIME after login—whether I used email targeting or random rollout.

Why undefined?

When you call posthog.isFeatureEnabled() immediately after login, PostHog hasn't finished loading the flags yet.

Here's the timeline:

  1. User logs in → posthog.identify() is called ✅

  2. User redirects to /tasks → Page loads immediately

  3. TaskItem renders → Checks flag

  4. PostHog is still loading flags → Returns undefined

  5. No Edit button shows (because undefined is falsy)

If I refreshed the page:

Inline Task Editing Enabled: true

Now it works! Because PostHog cached the flags.

The Fix: posthog.reloadFeatureFlags()

I found this method in PostHog's docs. It forces PostHog to reload flags immediately after identifying a user.

What reloadFeatureFlags() does:

Tells PostHog: "Hey, I just identified a user. Go fetch their flags NOW." By the time the /tasks page renders, the flags are ready.

I moved flag loading into the login flow, and only redirected after the flags finished loading.

Updated LoginForm:

The key change: Making the function async and using await so the redirect happens ONLY after flags are loaded.

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!email) return;

    setIsLoading(true);

    try {
      // 1. Save user to localStorage
      const user = auth.login(email, name || undefined);

      // 2. Identify user in PostHog
      posthog.identify(email, {
        email,
        name: name || undefined,
        identified_at: new Date().toISOString(),
      });

      // 3. Capture login event
      posthog.capture("user_logged_in", {
        email,
      });

      // 🎯 4. RELOAD FEATURE FLAGS - Wait for them to load!
      await posthog.reloadFeatureFlags(); // ← The "await" is critical!

      console.log("Feature flags loaded!");

      // 5. Redirect to tasks (only after flags are ready)
      router.push("/tasks");
    } catch (error) {
      console.error("Login error:", error);
    } finally {
      setIsLoading(false);
    }
  };

Result after adding this:

Edit button appears immediately. No refresh needed!

No Flicker Problem

I expected to see the Edit button "pop in" after a delay (the famous flicker problem).

But I didn't see any flicker.

The flag loaded so fast (~100-200ms) that by the time I clicked the dropdown menu, the Edit button was already there.

PostHog is FAST. The flag loaded before I could even interact with the UI.

This is why reloadFeatureFlags() works so well. It kicks off the flag loading immediately during login, so by the time you're navigating the app, flags are ready.

The Other Mistake I Made

I also combined TWO things without realising:

  1. Target specific user (email = onyenekwelizabeth@gmail.com)

  2. 50% rollout

What PostHog did:

  • First check: Does email match?

  • Second check: Is user in the random 50%? ❌

So even though the email matched, the rollout percentage still applied!

PostHog showed: "50% of 👤 Email address = ...beth@gmail.com"

That means:

  • Only users with that email are eligible

  • Of those users, only 50% see it

Since I only had ONE user with that email, they had a 50/50 shot.

They lost the coin flip. 😅

PostHog even warned me: “Will match approximately 13% (0/4) of total users"

But I didn't notice. 🤦‍♀️

What I should have done:

  • Email = onyenekwelizabeth@gmail.com

  • Rollout = 100%

Then that user ALWAYS sees it.

I felt silly. But I learned! 🎓

The Three Served Value Options

While creating the flag, I saw three options for how flags work:

1. Release toggle (boolean)

Returns true or false.

What I used: This one. ✅

Example:

const canEdit = posthog.isFeatureEnabled("inline-task-editing");
// Returns: true or false

When to use: Simple on/off features.

2. Multiple variants with rollout percentages (A/B testing)

Returns a variant key (like "control", "variantA", "variantB").

Example use case:
Testing two different UI designs.

Setup in PostHog:

  • Variant A (50%): "blue-button"

  • Variant B (50%): "green-button"

Code:

const variant = posthog.getFeatureFlag("button-color-test");

if (variant === "blue-button") {
  return <Button color="blue">Click Me</Button>;
} else if (variant === "green-button") {
  return <Button color="green">Click Me</Button>;
}

When to use: A/B testing different versions of a feature.

3. Remote config (single payload)

Returns a JSON payload with configuration values.

Example use case:
You want to change text or settings without redeploying code.

Setup in PostHog:

{
  "welcome_message": "Welcome to TaskFlow!",
  "max_tasks": 100,
  "theme": "dark"
}

Code:

const config = posthog.getRemoteConfigPayload("app-config");

console.log(config.welcome_message); // "Welcome to TaskFlow!"
console.log(config.max_tasks); // 100

When to use:

  • Changing text/copy without code changes

  • Updating configuration values remotely

  • Emergency tweaks (like disabling a buggy feature)

Note: Remote config flags require server-side setup with your feature flags secure API key. They're meant for backend use, not frontend (because the key needs to stay secret).

For Day 9, I stuck with simple boolean flags. Remote config is more advanced.

What I Learned (Day 9 Takeaways)

1. Feature flags = safety net

Ship features without fear. If something breaks, flip the switch. Done.

2. 50% rollout is truly random

Don't expect exactly half your users. It's probability, not precision.

3. Always track usage

Don't just implement the flag. Track if people USE it.

Events = data = decisions. ✅

4. Flags return undefined on first load

Whether using random rollout or email targeting, flags aren't ready immediately after login.

isFeatureEnabled() returns undefined until PostHog finishes loading flags.

The fix: await posthog.reloadFeatureFlags() in your login handler.

This ensures flags are ready before the page renders. ✅

5. Start small, then expand

Real rollout strategy:

  • 10% (you + team)

  • 50% (if no bugs)

  • 100% (full release)

  • Remove flag from code (it's permanent now)

6. Feature flags aren't just for new features

Use them for:

  • Kill switches (turn off buggy features)

  • Beta access (give early adopters a preview)

  • A/B testing (compare two versions)

  • Gradual migrations (old code → new code safely)

What Worked Great

✅ Creating the flag (2 minutes)
✅ Implementing it in code (simple conditional)
✅ Tracking events (see actual usage)
✅ Rolling out safely (no production disasters)
✅ Testing both states (incognito mode trick)

My Recommendations

Use feature flags when:

  • Releasing risky changes

  • Building experimental features

  • Testing new UI designs

  • Gradually migrating code

  • You need a kill switch

Don't use feature flags for:

  • Simple bug fixes (just deploy them)

  • Features you're 100% confident about

  • Things that don't need gradual rollout

Best practices:

  • Start with percentage rollout (10% → 50% → 100%)

  • Always track usage events

  • Remove flags after full rollout (clean up your code!)

  • Document what each flag does

  • Set auto-deletion in PostHog (don't keep them forever)

Up Next: Day 10

So far:

Day 1: What is PostHog? ✅
Day 2: Setup (SSR bug) ✅
Day 3: Empty dashboard ✅
Day 4: Toolbar (Actions struggle) ✅
Day 5: Custom events (AI magic) ✅
Day 6: Session replay (UX goldmine) ✅
Day 7: User identification ✅
Day 8: Funnels (settings overload) ✅
Day 9: Feature flags (shipping without fear) ✅

Coming up:

Day 10: A/B Testing – What PostHog Does Well (And What It Doesn’t)
Day 11+: More features...

Stay tuned!

Follow along with the series:
- 🎥 TikTok: [https://www.tiktok.com/@hey_techys?is_from_webapp=1&sender_device=pc]
- 📸 Instagram: [https://www.instagram.com/hey_techys]
- 💼 LinkedIn: [https://www.linkedin.com/in/onyenekwe-elizabeth-46a467183/]
- 🐦 Twitter/X: [https://x.com/ElizabethOnyen6]

Byeeeeeeee!!!!

- Lizzy