Choosing a Flag Evaluation Strategy

This guide is part of the Backend Evaluation & Server-Side SDKs series. Before you wire a single SDK client, one decision shapes latency, privacy, and blast radius for every flag you ship: where and how a flag is evaluated. This guide frames that decision as three linked axes — execution location, evaluation locality, and SDK abstraction — and gives you a repeatable way to place each flag on them.

The reason this deserves a dedicated decision rather than a house default is that the wrong answer is expensive to reverse. Execution location leaks into your API contract: once a flag is resolved in the browser, the rule that produced it is effectively public, and you cannot un-publish it. Evaluation locality leaks into your operational posture: once services depend on a remote evaluation endpoint being reachable on the request path, that endpoint becomes a hard dependency you have to keep at four or five nines. SDK abstraction leaks into every call site: once vendor types are imported across hundreds of files, swapping providers is a multi-sprint refactor rather than a one-line configuration change. Each axis, chosen carelessly, becomes a constraint you inherit for the life of the flag — and often long after the flag itself is retired.

The three evaluation-strategy axes Three decision axes: execution location (server versus browser), evaluation locality (local in-process versus remote call), and SDK abstraction (OpenFeature versus a vendor SDK). Each flag is placed on all three. One flag, three axes 1 · Execution location server (private rules, trusted context) ↔ browser (presentational, low round-trips) 2 · Evaluation locality local in-process (sub-ms, needs rule sync) ↔ remote call (always fresh, adds latency) 3 · SDK abstraction OpenFeature (portable, vendor-swappable) ↔ vendor SDK (native features, lock-in)
The three axes are independent: a flag can be server-evaluated, locally resolved, and read through an OpenFeature provider all at once. Placing each flag deliberately is the whole job.

Problem Framing

Teams usually inherit an evaluation strategy by accident — whichever SDK the first engineer imported becomes the default for every flag thereafter. That works until a flag that gates a pricing rule leaks its targeting logic to the browser, or a flag on the checkout hot path adds a 40 ms remote call to every request. The failure modes are not hypothetical: they are the predictable result of applying one strategy uniformly to flags with very different privacy and latency requirements.

This guide covers how to choose between server-side and browser evaluation, between local in-process and remote resolution, and between the vendor-neutral OpenFeature abstraction and a vendor’s native SDK. It does not re-derive the mechanics of each — those live in the dedicated guides on server-side SDK integration patterns, distributed caching, and polling vs streaming synchronization. Here the focus is the decision itself.

The stakes scale with flag volume. A team running a dozen flags can absorb a bad default through sheer manual attention — someone notices the pricing rule in the network tab and files a bug. A team running several hundred flags cannot, because no human is reviewing every bootstrap payload on every deploy. At that volume the strategy has to be encoded, defaulted, and enforced, so that a new flag inherits a correct default and a wrong one is caught by a test rather than by an incident. That is why the last step of this procedure — recording the decision as machine-readable metadata — is not bookkeeping but the load-bearing part: it is what lets the decision survive contact with a growing flag inventory and a rotating set of authors.

Two failure modes of an accidental strategy Applying one strategy uniformly produces two failures: a sensitive flag evaluated in the browser leaks its targeting rule, and a hot-path flag evaluated remotely adds a round-trip to every request. Wrong location pricing rule evaluated in the browser targeting logic leaks Wrong locality checkout flag evaluated with a remote call +40 ms per request
Both failures come from one mistake — applying a single strategy to flags with different privacy and latency needs. The matrix exists to prevent exactly this.

Prerequisites

These inputs are not busywork you gather to satisfy a process; each one directly resolves one axis of the decision. The sensitivity tags resolve execution location, the latency budgets resolve locality, the urgency labels tell you whether a local flag needs streaming sync or can tolerate polling, and the portability answer resolves the abstraction. If you find yourself unable to produce one of these inputs — you cannot say what a flag’s targeting reads, or you have no measured budget for the path it sits on — that gap is the real blocker, and no amount of matrix reasoning downstream will paper over it. Producing the inputs is often where teams discover that a flag they thought was presentational actually reads an entitlement, or that a path they assumed was cold is evaluated inside a tight loop. Treat any missing input as a finding, not a formality.

Inputs the decision needs Four inputs feed the strategy decision: a flag inventory tagged by data sensitivity, per-path latency budgets, propagation-urgency labels, and a real portability requirement. Flag inventory tagged by sensitivity Latency budgets per request path Urgency labels instant vs eventual Portability need real, not hypothetical
Without the sensitivity tags and latency budgets, the decision collapses into guesswork — these inputs are what make it repeatable.

Core Concept & Architecture

The three axes are orthogonal, which is what makes a matrix useful: a decision on one does not force a decision on the others. But they interact through a small number of hard constraints. Sensitive targeting data forces server-side execution. A strict latency budget on a hot path pushes toward local evaluation. A genuine multi-vendor future argues for OpenFeature. Everything else is a soft preference you can revisit.

The canonical safe default for a backend service is server-side execution, local in-process evaluation, read through an OpenFeature provider. It keeps rules private, keeps latency sub-millisecond, and keeps you free to swap vendors. You deviate from that default only when a flag has a specific reason to — a purely presentational toggle that never touches sensitive data can be resolved in the browser; a flag whose correctness matters more than its latency can accept a remote call.

Treat “orthogonal” precisely: it means the axes are chosen independently, not that every one of the eight combinations is sensible. Some corners of the cube are contradictions in practice. Browser execution combined with local in-process evaluation, for instance, means shipping the whole rule set and every flag’s targeting logic to the client — which is exactly the leak the server-side gate exists to prevent, so a sensitive flag can never sit there. Remote evaluation on a strict hot path is another dead corner: it satisfies the abstraction axis but violates the latency budget that put the flag on that path in the first place. The matrix is useful because it forces you to notice these collisions before you build them, rather than discovering the contradiction when a p99 alert fires or a security review flags a leaked rule.

The hard constraints resolve in a fixed priority order, and that order matters when two of them pull in opposite directions. Privacy wins first: a flag that reads sensitive targeting data is server-side, full stop, even if that costs you the round-trip savings of browser resolution. Latency wins second, among the flags privacy has already pinned to the server: a tight budget forces local evaluation and, with it, the obligation to run a sync mechanism. Portability wins last, because it is the only one of the three that is genuinely reversible later — you can migrate an application from a vendor SDK to OpenFeature far more cheaply than you can un-leak a pricing rule or retrofit a sync pipeline onto a service that assumed a remote endpoint. Resolve the axes in that order and you never trade an irreversible property away for a reversible one.

# flag-strategy.yaml — annotate each flag with its chosen strategy
flags:
  web.pricing.regional-discount:
    execution: server        # targeting reads customer tier — must stay private
    locality: local          # on the checkout hot path, sub-ms required
    abstraction: openfeature # portable across the two vendors we evaluate
  web.nav.holiday-banner:
    execution: client        # purely presentational, no sensitive input
    locality: remote         # correctness over latency; low traffic
    abstraction: openfeature
  ops.payments.kill-switch:
    execution: server
    locality: local          # must resolve even if the control plane is down
    abstraction: openfeature # provider-agnostic so failover is uniform

Storing the chosen strategy alongside each flag key turns an implicit decision into a reviewable one. A pull request that adds a flag now has to declare its execution location, and a reviewer can catch a sensitive flag mistakenly marked client before it ships. Notice the third flag in the example — ops.payments.kill-switch is marked local specifically so it resolves even when the control plane is unreachable; a kill switch that depends on a remote call to disable something is worthless during exactly the incident you built it for. That single annotation encodes a real failure-mode decision that would otherwise live only in one engineer’s head.

The YAML above is the source of truth, but it is only worth as much as its coupling to reality. Keep it next to the flags it describes — ideally generated from or validated against the same definitions your provider loads — so it cannot silently diverge. A strategy file that lists flags which no longer exist, or omits flags that shipped last week, is worse than none, because it lends false confidence to the CI check that reads it. The reconciliation is cheap: enumerate the live flag keys from the provider, diff against the keys in the strategy file, and fail the build on any key present in one but not the other.

Decision tree for placing a flag Start from the flag. If targeting reads sensitive data, execute server-side; otherwise the browser is allowed. If the flag is on a strict latency path, evaluate locally; otherwise a remote call is acceptable. If more than one vendor is in play, use OpenFeature. New flag Targeting reads sensitive data? tier, PII, entitlements Server-side yes → rules stay private Browser allowed no → presentational then: strict latency → local; multi-vendor → OpenFeature
The first branch is the only hard gate — sensitive targeting forces server-side. The latency and vendor questions are refinements applied afterward.

Step-by-Step Decision Procedure

Step 1 — Classify each flag by data sensitivity

Walk the flag inventory and tag every flag by whether its targeting rules read data that must not reach the browser: customer tier, entitlements, internal user IDs, or anything derived from PII. Any flag that reads such data is pinned to server-side execution regardless of other considerations.

# classify.py — tag flags by the sensitivity of their targeting inputs
SENSITIVE_ATTRS = {"customer_tier", "entitlements", "internal_uid", "plan", "email"}

def execution_location(flag: dict) -> str:
    inputs = set(flag.get("targeting_inputs", []))
    if inputs & SENSITIVE_ATTRS:
        return "server"        # hard constraint — rules must stay private
    return "client-allowed"    # presentational; browser evaluation is safe

Pitfall: “the browser already knows the user’s tier” is not a reason to evaluate there. Even if the tier is visible, the rule that maps tier to variant is business logic you rarely want to publish. Keep the mapping server-side.

Be deliberate about transitive sensitivity, too. A targeting input can look innocuous — a boolean like is_beta_cohort or a bucketing hash — and still be sensitive because of what a competitor or a curious user can infer from it. If publishing the rule reveals the shape of an unreleased pricing experiment, the rollout percentage of a controversial feature, or the existence of an enterprise tier you have not announced, treat the input as sensitive even though no single field is PII. The test is not “does this field contain personal data” but “would I be comfortable posting this targeting rule publicly”; if the answer is no, it belongs on the server. When you are unsure, err toward server-side: the cost of an unnecessary server evaluation is a few microseconds, while the cost of an unnecessary leak is a rule you can never take back.

Step 2 — Apply the latency budget to pick locality

For each server-side flag, check the request path’s latency budget. If the flag sits on a hot path with a tight p99, resolve it locally against a synced rule set. If the path is low-traffic or latency-tolerant, a remote call is acceptable and buys you always-fresh values without a sync mechanism.

# locality.py — choose local vs remote from the path budget
def locality(flag: dict, path_p99_budget_ms: float) -> str:
    # A remote evaluation typically adds 10-40ms; keep it off tight paths.
    if path_p99_budget_ms <= 50 or flag.get("hot_path"):
        return "local"     # sub-ms, requires background rule sync
    return "remote"        # always fresh, no sync, but adds a round-trip

Pitfall: local evaluation is only as fresh as its last sync. If a flag must reflect a change within milliseconds, pair local evaluation with streaming synchronization rather than falling back to a remote call.

When you budget the latency, budget the tail, not the mean. A remote provider that averages 8 ms can still spend 60 ms at p99 because of connection setup, a cold DNS lookup, a garbage-collection pause on the provider, or head-of-line blocking behind a slow neighbor on a shared connection. Feature-flag reads sit on the critical path of a user request, so their p99 is what the user experiences on a bad day — and bad days are when your rollouts matter most. A budget expressed as “p99 of the request path minus the p99 of every other dependency” is the honest one; the mean will flatter a remote provider into looking safe on paths where it is not. Also account for fan-out here rather than deferring it: a path that reads one flag remotely at 20 ms is tolerable, but the same path grown to read six flags is quietly spending 120 ms unless those reads are batched, and fan-out tends to grow silently as features accrete flags.

Step 3 — Decide the SDK abstraction

If you are genuinely evaluating more than one vendor, or expect to migrate, standardize on an OpenFeature provider so the application code never imports a vendor type. If you are firmly committed to one vendor and rely on a feature only its native SDK exposes, the vendor SDK is a defensible choice — but wrap it behind your own thin interface so the commitment stays reversible.

// The portable read — identical whether the provider is flagd, a vendor, or in-memory
import { OpenFeature } from '@openfeature/server-sdk';

const client = OpenFeature.getClient();
const showDiscount = await client.getBooleanValue(
  'web.pricing.regional-discount', false, evaluationContext,
);

Pitfall: adopting OpenFeature and then reaching for provider.vendorSpecificMethod() in application code re-introduces the lock-in you paid to avoid. Keep vendor-specific calls inside a custom provider, not scattered through the codebase.

Be honest about whether portability is a requirement or an aesthetic preference, because the abstraction is not free. OpenFeature costs you a layer of indirection, a slightly larger dependency surface, and the discipline of expressing everything — targeting context, error handling, evaluation reasons — through its generic types rather than a vendor’s richer native API. If you are one team, on one vendor, with no procurement pressure and no history of migrations, that cost buys an option you may never exercise. The strongest reasons to pay it anyway are concrete: you are actively bake-off-ing two vendors and want identical call sites during the evaluation; you run a polyglot fleet and want one mental model across Go, Java, and Node services; or you have been burned before by a vendor deprecating an SDK on short notice. Absent one of those, wrapping a single vendor SDK behind your own thin interface gives you most of the reversibility at a fraction of the ceremony — and you can adopt OpenFeature later, when the second vendor actually appears, with the wrapper as your migration seam.

Step 4 — Record the decision next to the flag

Write the three-axis choice into the flag’s metadata so it is visible in review and enforceable in CI. A flag without a declared strategy is a flag whose strategy will drift.

# ci-check.sh — fail the build if a new flag lacks a declared strategy
missing=$(yq '.flags | to_entries[] | select(.value.execution == null) | .key' flag-strategy.yaml)
if [ -n "$missing" ]; then
  echo "Flags missing an evaluation strategy: $missing" >&2
  exit 1
fi
The four decision steps Classify by sensitivity, apply the latency budget for locality, decide the SDK abstraction, then record the decision in flag metadata. 1 Classify sensitivity 2 Locality latency budget 3 Abstraction OF vs vendor 4 Record metadata + CI
Step 4 is what keeps the other three from decaying — an unrecorded decision is indistinguishable from no decision a quarter later.

Verification & Testing

Confirm the strategy is actually implemented, not just declared. A declared strategy that no test enforces decays into a comment: it describes what someone once intended, not what the code does. The gap between the two is where privacy leaks and latency regressions live, because both are invisible in normal operation — the sensitive flag works fine in the browser right up until someone reads the network tab, and the stray remote call is fast enough not to trip an alert until fan-out or a slow day pushes it over the budget. Assert that flags marked server never appear in the browser bootstrap payload, and that flags marked local resolve without a network call, so the declaration and the behavior are checked against each other on every build rather than during an incident.

// strategy.test.ts — enforce the declared strategy at test time
import strategy from './flag-strategy.yaml';

test('server-only flags never leak into the client bootstrap', () => {
  const clientBootstrap = buildClientBootstrap(); // your real bootstrap builder
  for (const [key, cfg] of Object.entries(strategy.flags)) {
    if (cfg.execution === 'server') {
      expect(Object.keys(clientBootstrap)).not.toContain(key);
    }
  }
});

A dry-run in staging closes the loop: evaluate every local flag with the control plane unreachable and confirm each returns its intended value from the last synced rule set rather than the coded default. The distinction is subtle and worth asserting explicitly — a local flag that falls through to its hard-coded default during an outage looks healthy in a smoke test but is silently ignoring the last operator decision, which is precisely the wrong behavior for a kill switch. Kill the network to the control plane, keep the process alive, and assert the variant, not merely that the call did not throw.

Two further checks are worth automating because they catch drift that a point-in-time review misses. First, assert that the strategy file and the live provider agree on the set of flag keys, so a flag added without a strategy declaration fails the build rather than shipping with an undeclared default. Second, in whatever synthetic or load test exercises your hot paths, count the outbound flag-evaluation network calls and assert the count is zero for paths whose flags are all marked local; a single stray remote call on a hot path is the kind of regression that a latency dashboard will eventually surface as a p99 creep weeks later, long after the commit that caused it has scrolled out of memory. Catching it at test time turns a slow mystery into a fast, obvious failure.

Two verification assertions A test asserts that server-only flags are absent from the client bootstrap, and a staging dry-run with the control plane unreachable confirms local flags resolve from the synced rule set. Bootstrap test server flags absent from client payload Offline dry-run control plane down local flags still resolve
The first check proves privacy; the second proves the locality choice survives a control-plane outage.

Troubleshooting & FAQ

A flag was marked client-side but now needs private targeting — how do I migrate it?

Flip its execution to server, remove it from the client bootstrap, and expose it to the browser only as a resolved variant (never the rule). The client code that read the flag directly should now read the server-supplied variant. This is the same boundary described in securely passing flags to the browser.

We adopted OpenFeature but latency got worse. Why?

You are almost certainly using a remote provider where a local one would do. OpenFeature is an abstraction, not a transport — pairing it with an in-process provider that evaluates a synced rule set keeps latency sub-millisecond. Check whether your provider makes a network call per evaluation.

Is remote evaluation ever the right default?

For low-traffic internal tools where freshness matters more than latency and you do not want to run a sync mechanism, yes. The remote call is simpler to operate. It becomes the wrong default the moment a flag lands on a user-facing hot path.

Can a single flag use different strategies on different services?

Yes, and sometimes it should. The three axes are properties of an evaluation site, not of the flag definition, so the same flag key can be resolved locally in a latency-sensitive checkout service and remotely in a low-traffic admin tool that values freshness. What must stay consistent is the targeting logic and the default — both come from the one rule set — not the transport. Record the strategy per service if your inventory spans services with genuinely different budgets, but keep the flag’s semantics defined once so the two sites never disagree on what the flag means.

How do I choose a strategy for a flag whose targeting context isn’t available server-side?

If the deciding attribute genuinely only exists in the browser — a viewport width, a device capability, a client-only interaction state — you have two honest options. Evaluate in the browser if the rule is not sensitive, accepting that the logic is public. Or lift the attribute into the server request, by sending it as a trusted, validated field in the API call, and keep evaluation server-side. Prefer lifting the attribute whenever the rule is sensitive; only fall back to browser evaluation when both the attribute and the rule are safe to expose. Never split the difference by shipping a sensitive rule to the client just because one of its inputs lives there.

Should a kill switch ever be evaluated remotely?

Almost never. A kill switch exists to disable something during an incident, and incidents are exactly when networks degrade and control planes get overloaded. If disabling the broken thing requires a successful remote call, the switch can fail in the same failure it was meant to contain. Evaluate kill switches locally against a synced rule set so they resolve from memory even when the control plane is unreachable, and make sure their last-synced value — not a coded default — is what survives the outage.

Does the strategy choice change how I test a flag?

It changes what you must assert. A server + local flag needs a test that it is absent from any client bootstrap and a dry-run that it resolves offline from the synced rule set. A client flag needs a test that no sensitive attribute reaches its evaluation. A remote flag needs a timeout and fallback test, because you have accepted a network dependency and must define what happens when it is slow or down. The strategy annotation is effectively a contract, and each value on each axis implies a specific assertion you owe the flag.

Performance & Scale Considerations

Per-request latency amplifies with flag fan-out Twelve flags read per request cost almost nothing locally but multiply a remote per-call latency into hundreds of milliseconds unless the reads are batched into a single call. 12 flags local 12 memory lookups < 1 ms 12 flags remote 12 round-trips ~300 ms 12 flags batched 1 round-trip ~25 ms
Fan-out is the multiplier that decides hot-path viability: local absorbs it, unbatched remote does not, and batching claws most of it back.

The strategy choice has a direct latency signature. Local in-process evaluation is typically well under a millisecond because it is a data-structure lookup against a rule set already in memory. A remote evaluation adds a network round-trip — often 10–40 ms depending on placement — to every call, which is invisible on a settings page and catastrophic on a checkout path evaluated dozens of times per request. At scale, the number of evaluations per request matters more than the per-evaluation cost: a page that reads twelve flags amplifies a 20 ms remote call into 240 ms. Prefer local evaluation on anything hot, and if you must go remote, batch the reads into a single call rather than issuing one per flag. The distributed caching and rule engine optimization guides cover how to keep local evaluation fast as the rule set grows.

Locality also has a memory and startup signature that the latency numbers hide. Local evaluation trades network cost for a resident copy of the rule set in every process, plus the machinery that keeps it synced — a streaming connection or a polling loop per instance. On a fleet of a few hundred pods that is usually trivial, a few megabytes and one long-lived connection each, but it is not nothing: a very large rule set, or a very high pod count, can turn the sync fan-in into real load on the control plane, which is one more reason the streaming versus polling choice belongs in the same conversation as locality. Local evaluation also moves a cost to startup — a freshly scheduled pod must complete its first sync before its flag reads are trustworthy, so a service that scales up under load is briefly serving coded defaults on its newest instances unless it blocks readiness on the initial sync. Gate the readiness probe on that first successful sync and the problem disappears; forget to, and you get a burst of default-valued evaluations every time the autoscaler adds capacity, precisely when traffic is highest.

Remote evaluation has the inverse profile: near-zero per-instance memory and no startup sync, but a hard runtime dependency whose availability and tail latency are now yours to defend. The right way to make remote evaluation survivable on anything approaching a hot path is a short timeout with a defined fallback and a local cache in front of it, which collapses the common case back to a memory lookup and reserves the network call for cache misses. At that point you have reinvented a coarse form of local evaluation, which is a useful signal: if you find yourself adding a cache, a timeout, and a fallback to a remote provider to meet a latency budget, the budget was telling you to evaluate locally from the start.