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.

The four scoring factors Four factors decide the evaluation boundary: privacy of targeting data (favors server), latency to render (favors client), cross-request consistency (favors server), and offline resilience (favors client cache). What pulls each way Pulls to SERVER private targeting rules cross-request consistency Pulls to CLIENT zero-latency render decisions offline / cached resilience Tie-breaker any privacy pull outweighs every latency pull
Privacy and consistency pull to the server; latency and offline resilience pull to the client. When they conflict, privacy wins outright — a leaked rule cannot be un-leaked.

Prerequisites

Facts to gather per flag Gather the flag's data sensitivity, its render-path latency budget, its consistency requirement, and whether the surface must work offline. Sensitivity public / private Latency budget render path Consistency per-user / per-device Offline need PWA / mobile web
These four facts feed the four matrix rows — gather them once per flag and the score falls out.

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
The four matrix steps Score the flag on all four factors, expose only the resolved variant for server wins, keep inputs non-sensitive for client wins, then record and enforce the boundary. 1 Score four factors 2 Server win variant only 3 Client win safe input 4 Enforce CI guard
Steps 2 and 3 are mirror images — one hides the rule, the other verifies the input was never sensitive to begin with.

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
Boundary verification in the response The rendered response should contain the resolved variant for a server-boundary flag but zero occurrences of the sensitive targeting attributes that produced it. variant present "regional-discount": true inputs absent customer_tier count = 0
Both conditions must hold: the variant is present so the feature works, and the sensitive input is absent so the rule stayed private.

Gotchas & Edge Cases

Three boundary edge cases Hybrid flags evaluated on both sides can diverge, a client flag on a shared cached page can serve one user's variant to another, and a public input today can become sensitive after a schema change. Hybrid divergence both sides evaluate values disagree Cached cross-user shared CDN page wrong variant served Input drifts private re-classify on schema change
The middle case is the sneaky one — a client-evaluated flag on a CDN-cached page can hand one user's variant to the next visitor.

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.