Chapter 02

Server and client-side flags

PostHog can evaluate a flag in the browser, on the server, or in both places. Choose the runtime based on where the gated code runs and which side has the data needed to evaluate it. An agent adding flag checks can follow the same rules Fig. 1.

feature-flag-evaluation/SKILL.md
---
name: feature-flag-evaluation
description: >
Add or review PostHog feature flag checks so each flag is evaluated once, where its code and
data live, for the right person.
---
# Feature flag evaluation
Read the current docs first. Every PostHog docs URL returns Markdown if you append `.md`:
- https://posthog.com/docs/feature-flags/best-practices.md
- https://posthog.com/docs/feature-flags/local-evaluation.md
- https://posthog.com/docs/feature-flags/bootstrapping.md
## Rules
1. Evaluate each flag on the side that runs the code it gates and holds the properties its
conditions use. Default to one side per flag. When both sides need the value, evaluate on the
server and bootstrap it into the client for the first render. The client evaluates again once
/flags responds, so the distinct ID and properties must match on both sides (rule 4). Set
server-only flags to the "Server-side only" evaluation runtime.
2. Check each flag once, where the request or screen starts, and pass the value down.
3. Keep flag keys in one constants module per language. A template script that cannot import it
gets the key from the server's constant, never a string literal.
4. Evaluate with the identified user's distinct ID. It must be the same ID on the server and in
the browser, and any property a condition filters on must have the same value on both sides.
5. On the server, prefer local evaluation. SDKs poll flag definitions every 30 seconds (every 5
minutes in Go). Billing counts the polls, not the checks: a poll that downloads definitions
counts as 10 flag requests, and one where nothing changed counts as 1. Local evaluation needs
a project secret API key with the `feature_flag:read` scope. Never ship it to a client.
6. Give every check an explicit default for "not loaded" and "unreachable", and make that default
the old behavior.
7. Where the server already evaluated a client flag, bootstrap it into the client SDK instead of
waiting for /flags. The web, React Native, iOS, Android, and Flutter SDKs support bootstrapping.
Bootstrap every flag the page checks, including the ones that evaluated to false. The web SDK
keeps a bootstrapped false, and the first full /flags response replaces every bootstrapped
value.
## Finally
List every flag you touched: where it is evaluated, its call site, and how the distinct ID is
chosen. Say which checks you could not move to a single call site and why.
Fig. 1 – A skill that applies this page's rules. Copy it and give it to your agent before it adds your next flag check.

Choose where a flag is evaluated

Evaluate a flag on the side that runs the code it gates and holds the properties its release conditions filter on.

Evaluate onWhenHow
ServerThe gated code runs on the server, or the conditions use data only the server has, like the plan or account ageevaluateFlags(user.id), with local evaluation
BrowserThe gated code is UI, and the browser knows every property the conditions usegetFeatureFlag(), after identify()
Server, then browserBoth sides need the answer, or the UI needs it for the first renderEvaluate on the server and bootstrap the result for the first render; the browser then evaluates again

Default to one side per flag. A flag evaluated separately on the server and in the browser gets two chances to disagree, because each side can have a different distinct ID and different properties. When both sides need the answer, bootstrap the server's result for the first render; the browser then evaluates again, so identity and properties must match. For a server-only flag, set its evaluation runtime to Server-side only so the browser SDK doesn't load it at all.

Ask whether a person should ever see the other side of the flag. A browser flag hides a feature, but it doesn't protect it: the gated code ships in your JavaScript bundle, and the flag's answer shows in the network tab. If a leak would hurt, like an unannounced launch or new pricing, gate it on the server so the code never reaches the browser.

On the server, evaluate locally

With local evaluation, the server SDK downloads your flag definitions on a schedule and evaluates them in-process, so a check makes no network request. Billing counts the definition downloads, not the checks, so local evaluation is cheaper as well as faster.

The trade-off is that you pass in the properties your conditions use, like the person's plan, and on the server you usually have those already. Local evaluation also needs a project secret API key, which stays on the server. Never ship it to a browser or a mobile app.

When a check can't be evaluated locally, for example before the first definitions fetch lands, the SDK asks PostHog instead, unless you set onlyEvaluateLocally. Give every check a default for the case where neither gives an answer.

Keep identity and properties consistent

For most flags the hash uses the distinct ID, so if your server checks with user.id while the browser still has an anonymous ID, one person can get a different answer on each side. In the browser, call identify() with the ID your server uses, before any flag check.

Properties need the same care. If a condition filters on plan, the server passes the plan it knows, and the browser needs the same value, either as a person property or set with setPersonPropertiesForFlags() before the check. A side that doesn't have the property can reach a different answer from the side that does.

Pass the server's result to the client

If the server already knows the answer, send it with the page. Bootstrapping passes those values into posthog.init under bootstrap.featureFlags, so the browser has them before the first render and never flashes the old version.

Bootstrap every flag the page checks, including the ones your server evaluated as off. A flag you leave out reads as undefined until /flags responds, so your default in code decides what renders.