Client-Side vs Server-Side Evaluation Decision Matrix
This how-to is part of Choosing a Flag Evaluation Strategy. You have a specific flag in hand and need to decide where it is evaluated: in the browser, close to the render, or on the server, behind your trust boundary. The wrong call either leaks targeting logic to anyone who opens DevTools or bolts a network round-trip onto a hot path. This matrix scores the choice on the four factors that actually decide it.
Note that this is a per-flag decision, not a per-application one. The same codebase routinely evaluates some flags in the browser and others on the server, and trying to force a single boundary across every flag is how you end up leaking a pricing rule to save a few milliseconds on a menu toggle. Run each flag through the matrix on its own. The whole exercise takes under a minute once you have the four facts in front of you, and it produces an answer you can defend in review — which matters, because “it felt faster on the client” is not a rationale that survives a security audit.
Prerequisites
Step-by-Step Procedure
Step 1 — Score the flag on all four factors
Give each factor a server-lean or client-lean mark. Privacy is not a normal factor — it is a gate: if targeting reads sensitive data, the flag is server-side and the other rows are advisory only. The asymmetry is deliberate. Latency, consistency, and offline behavior are all recoverable — you can add a cache, seed a bootstrap, or accept a slower render and fix it later. A leaked targeting rule is not recoverable: once plan == "enterprise" → 30% off has shipped in a client bundle, it is public forever, cached by every proxy and scraped into every archive, and rotating the rule only tells attackers what you changed. Treat the gate as non-negotiable and score the remaining three factors only to break ties among flags that clear it.
The three tie-break factors carry equal weight by default, but you know your system better than the tally does. If your render path already blocks on a dozen other calls, one more server round-trip for a flag is noise and the latency vote barely matters; if the surface is a checkout that must survive a flaky mobile network, the offline vote should probably count double. The votes are a starting position, not a verdict.
# score.py — produce a recommendation, with privacy as a hard gate
def recommend(flag: dict) -> str:
if flag["targeting_sensitive"]:
return "server" # hard gate — rules must stay private
votes = {"server": 0, "client": 0}
votes["server"] += 1 if flag["needs_cross_request_consistency"] else 0
votes["client"] += 1 if flag["render_latency_critical"] else 0
votes["client"] += 1 if flag["must_work_offline"] else 0
return max(votes, key=votes.get)
Step 2 — For server wins, expose only the resolved variant
When the score says server, resolve the flag behind the trust boundary and send the browser only the variant it needs — never the rule. This is the bootstrap pattern from securely passing flags to the browser. The key discipline is that the resolved value is the only thing that crosses the boundary. Not the context you evaluated against, not the reason the SDK returned for the resolution, not the list of variants that lost — just the winner. OpenFeature’s evaluation details carry a reason and variant that are useful in server logs but are a slow leak if you serialize the whole details object into the page; pluck the boolean or string value out and discard the rest.
Resolve every server-boundary flag the render depends on in one pass and emit them as a single frozen bootstrap object, rather than letting the client fetch them one at a time after hydration. Batching keeps the browser from making a waterfall of authenticated calls, and freezing the object signals that these values are settled for this request — a later client-side re-evaluation with a thinner context is exactly the divergence failure covered below.
// server: resolve privately, ship only the variant
const bootstrap = {
'web.pricing.regional-discount': await client.getBooleanValue(
'web.pricing.regional-discount', false, ctx),
};
// The browser receives { "...": true } — no tier, no rule, no context.
Step 3 — For client wins, keep the input non-sensitive
When the score says client, double-check that every targeting input is safe to expose. A flag keyed on device_type or viewport is fine in the browser; one keyed on plan is not, regardless of how latency-critical it is. The test is not “would I mind logging this attribute” but “would I mind a competitor reading it.” Anything the browser evaluates against is, by definition, in the browser — visible in the network tab, readable in the SDK’s in-memory store, and inspectable by any extension the user has installed. Device type, viewport, locale, and an opaque anonymous ID pass that test. Account tier, entitlement flags, internal cohort names, and anything derived from a signed-in identity do not.
Be equally careful about the ruleset the client SDK downloads, not just the context you pass in. A client-side SDK in local-evaluation mode pulls the full flag definition — including every targeting clause — down to the device, so a flag that targets on a sensitive segment leaks the segment boundaries even if you never pass that attribute from your own code. If the rule itself is the secret, the flag belongs on the server no matter how innocuous its inputs look at the call site.
// client-safe: targets only on data the browser legitimately owns
const compact = client.getBooleanValue('web.nav.compact-menu', false, {
targetingKey: anonId,
deviceType: 'mobile', // public, device-derived — safe to evaluate here
});
Step 4 — Record the boundary and enforce it
Write the chosen boundary into the flag metadata and add a CI check that no server-boundary flag key appears in client bundles. A boundary that is not enforced erodes with the next refactor — someone imports the server SDK’s flag key into a shared constants file, a bundler tree-shakes it into the client chunk, and six weeks later the rule is public with no one having made a conscious decision. The grep-based guard below is deliberately crude because crude survives: it does not care how the key got into the bundle, only that it is there.
Run the guard on the built client output, not the source, so it catches keys that arrive through transitive imports and generated code as well as direct references. Wire it into the same CI stage that blocks a merge, and make its failure message name the offending key and the file it landed in — a guard that fails with “server flag leaked” and no coordinates gets suppressed the first time it fires on a false lead. When a flag legitimately changes boundary, the metadata edit and the guard both move together in one reviewed commit, which is the audit trail you want.
# guard.sh — fail if a server-only flag key is bundled for the browser
for key in $(yq '.flags | to_entries[] | select(.value.execution=="server") | .key' strategy.yaml); do
grep -rq "$key" dist/client/ && { echo "server flag $key leaked to client bundle"; exit 1; }
done
Verification
Prove the boundary holds by inspecting a real rendered response. For a server-boundary flag, the raw HTML and any bootstrap payload should contain the variant but never the targeting attributes that produced it. Inspect the response as it leaves the server — curl against the actual URL, not the framework’s dev view — because the leak you care about is what a stranger receives, and dev tooling often strips or adds fields that production does not. Check both the initial document and any JSON the page fetches during hydration; a rule that is absent from the HTML but present in a follow-up /api/bootstrap call has not been kept private, it has just moved down one request.
Two independent conditions have to hold, and it is worth asserting both. The variant must be present, or the feature silently defaults off and you have shipped a privacy win that broke the page. The sensitive input must be absent with a count of exactly zero, not merely rare — a single occurrence means the attribute is reachable, and one leaked row is a full leak. Fold both greps into the same test so a regression on either side fails the build rather than waiting for a pen tester to find it.
# Confirm a server flag ships as a variant, not a rule, and its inputs never appear
curl -s https://staging.example/checkout | grep -o 'regional-discount":[a-z]*' # -> variant
curl -s https://staging.example/checkout | grep -c 'customer_tier' # -> 0
Gotchas & Edge Cases
- Hybrid evaluation can diverge. If the same flag is evaluated both server-side and client-side with different contexts, the two answers can disagree mid-session. The classic symptom is a flash: the server renders the enabled variant, the client re-evaluates with a thinner context that lacks the deciding attribute, and the feature blinks off on hydration. Pick one boundary per flag, or seed the client from the server value and forbid re-evaluation of that key on the client.
- Client evaluation on cached pages leaks across users. A browser-evaluated flag baked into a CDN-cached response serves the first visitor’s variant to everyone. This is worse than a stale render — if the flag targeted on anything user-specific, you have handed one person’s treatment to strangers, which for an entitlement flag is a real access-control bug. Scope such pages
private, add the deciding attribute to the cache key, or move the flag server-side. - Public inputs can become sensitive. An attribute that is safe to expose today can become sensitive after a data-model change.
regionlooks harmless until legal starts using it to gate a regulated feature;plan_tierwas fine when it had two public values and dangerous the day it grew aninternaltier. Re-run the classification whenever targeting inputs change, and tie the re-check to the schema migration so it cannot be skipped. - Default values leak intent too. A client-side flag that falls back to
falseand flips totruefor a targeted segment tells any observer that a hidden feature exists and roughly who gets it, even with the rule server-side. When the existence of a feature is confidential — an unannounced product, a security mitigation — keep the whole flag server-side so the browser never learns there was a decision to make. - SDK bundle size is a boundary cost, not just a latency one. Shipping a client-evaluation SDK plus its ruleset adds real weight to every page and grows with your flag count. If a surface only reads two or three flags, a server-resolved bootstrap is often lighter than the SDK you would ship to evaluate them in the browser — weigh the kilobytes, not only the round-trips.
Troubleshooting & FAQ
The same flag needs a fast client render AND private targeting. What now?
Split it. Evaluate the private targeting server-side to pick the variant, then let the client render that variant with zero latency from the bootstrap. The client never sees the rule; the render never waits on a call. That combination is exactly what SSR flag consistency delivers.
Is a flag keyed only on an anonymous ID safe to evaluate client-side?
Usually yes — an opaque anonymous ID carries no sensitive data and is a fine client-side targeting key. The risk is not the ID but any rule that maps it to a segment; keep that mapping server-side if it encodes business logic.
How do I stop engineers from quietly moving a flag to the client for speed?
Make the boundary a declared, reviewed field in flag metadata and enforce it in CI (step 4). A move then requires an explicit metadata change a reviewer can catch, not a silent import.
Does server-side evaluation mean I lose per-device targeting like viewport or theme?
No — it means you resolve those on whichever side owns the data. Device and viewport are client facts, so a flag that genuinely depends on them is a client-side flag by the matrix. What you must not do is smuggle a sensitive server rule into a client evaluation just because the same feature also cares about viewport; split the decision so the private part resolves on the server and the presentational part resolves in the browser.
What boundary should a flag read by both a mobile app and a web frontend use?
Evaluate it on the server and expose the resolved variant through your API to both clients. That keeps the rule in one place, guarantees the app and the web build agree, and means a rule change ships without a mobile store release. Duplicating a client-side ruleset across two platforms is how the two surfaces drift apart and one of them serves a stale segment for weeks.
Can I cache a server-side evaluation to get client-like latency?
Yes, and it is the usual way to close the latency gap — cache the resolved variant keyed by the targeting inputs, not the raw flag, so the cache entry is safe to reuse only for identical contexts. The trade-off is staleness: a cached resolution outlives a rule change until it expires, so keep the TTL short for flags you roll fast and pair it with the invalidation covered in the local-versus-remote latency guide.
How does a kill switch factor into the boundary decision?
An operational kill switch should almost always be server-side, because you need it to take effect for every user within a synchronization interval and you cannot rely on stale client caches or offline devices honoring it. Client-side evaluation is fine for gradual, presentational rollouts; it is the wrong boundary for anything you might need to yank instantly during an incident.