← All posts

Evaluating a feature flag should not be a network call

A feature flag that costs a network call per check adds latency to every request that checks it. For a service that evaluates flags on every incoming API call, that alone is disqualifying — but the quieter damage is behavioral. Once flag checks have a visible cost, developers start rationing them: hoisting them out of loops, caching results past their freshness, checking once per request what the logic wants checked at every decision point. An abstraction you have to budget isn’t much of an abstraction.

So the smplkit Flags SDK is built on one principle: evaluation is local. State loads into the process, changes arrive over a WebSocket and apply immediately, and the check itself never leaves the process. Most of the rest of the design falls out of that.

Two clients, because two jobs

The SDK exposes two clients for two different callers.

The runtime client is for application code that evaluates flags — lightweight, built for high-frequency access, local evaluation only. Initialization loads the current state of every flag in the environment, and a background WebSocket connection keeps that state current.

client = SmplClient(environment="production", service="payments-svc")

# Local evaluation — no network call
is_enabled = client.flags.boolean("checkout-v2", context)
variant = client.flags.string("pricing-experiment", context)

The management client is for scripts and tooling that manage flags programmatically — CI/CD pipelines, deployment scripts, test setup. It makes explicit API calls and returns the full flag resource.

mgmt = client.manage

# This calls the API
flag = mgmt.flags.get("checkout-v2")
flag.environments["production"]["enabled"] = True
flag.save()

Different initialization behavior, different failure modes, different performance characteristics. One combined client would compromise both directions: management concerns cluttering the hot path, and a WebSocket connection tagging along on every one-shot script.

Lazy initialization

The runtime client doesn’t touch the network until the first flag access. That first client.flags.boolean(...) establishes the WebSocket connection, fetches the current state of all flags for the environment, and caches it.

Lazy, for two reasons. First, startup ordering: an SDK that connects during module import or class instantiation runs inside the application’s startup sequence — before all environment variables are set, before service discovery has run, potentially before the flags service itself is healthy. Deferring to first use means the application is actually running by the time the connection happens. Second, tests: plenty of unit tests never evaluate a flag, and an eagerly connecting SDK would make every one of them mock at the module level. With lazy initialization, tests that don’t evaluate flags don’t know the SDK exists.

The cost is that the first evaluation is slower than the rest. We’ll take it: the first evaluation happens once per process startup; subsequent evaluations are nanoseconds.

The update pipeline

After initialization, the SDK holds a WebSocket connection to the flags service. When any flag changes — targeting rules, enabled state, a new environment override — the service broadcasts to every SDK client connected to that account and environment. The SDK updates its in-memory cache, and the next evaluation reflects the change. Console save to in-process update typically lands under 100ms.

Connection management is invisible to the application: a background thread in Python, Java, C#, and Ruby; an async task in TypeScript and Go; reconnection with exponential backoff when the connection drops. During a disconnection window, evaluations continue against the cached state — the SDK degrades to slightly stale instead of failing, which for feature flags is the correct direction to fail in.

The context object

Evaluation needs to know who’s asking. Enterprise plan? US region? Beta cohort? The Context class carries that information:

context = Context("user", "user-123", plan="enterprise", region="us-east-1")

A context has a kind (the type of entity, e.g., “user”, “device”, “request”), a key (the entity’s unique identifier), and arbitrary attributes — which is what the JSON Logic targeting rules evaluate against.

More than one context can be active at once:

contexts = [
    Context("user", "user-123", plan="enterprise"),
    Context("organization", "org-456", industry="finance"),
]
result = client.flags.boolean("enable-finance-features", contexts)

Rules can target either context independently — useful when both the user’s attributes and the organization’s decide the flag’s behavior.

The context provider

Most applications have a “current context” that applies to every evaluation in a request: the authenticated user, their subscription tier, their region. Instead of threading it through every call site, register a provider:

@client.flags.context_provider
def get_request_context() -> list[Context]:
    user = get_current_user()
    return [Context("user", user.id, plan=user.plan, region=user.region)]

When a flag is evaluated without an explicit context, the SDK calls the provider. The provider runs on each evaluation, not cached — so a user who upgrades their plan mid-session is evaluated as upgraded immediately, no stale cache in the way.

The rule builder

The management side gets a fluent Rule builder, for constructing targeting rules without hand-writing JSON Logic:

rule = (
    Rule.when("user.plan").equals("enterprise")
    .or_when("user.id").is_in(["user-123", "user-456"])
    .then("treatment")
    .otherwise("control")
)
flag.targeting_rules = [rule.to_json_logic()]

The builder produces valid JSON Logic. Application code never touches it — the runtime path deals only in the resulting values.

One WebSocket for everything

Flags shares a client with Config and Logging, and all three need live updates. Rather than three connections, the SDK multiplexes all three over a single WebSocket connection to the platform, with a channel prefix — flags:, config:, logging: — routing each message to its handler. Three independent SDK surfaces, one set of transport plumbing, and the application never sees any of it.

What we’d revisit

Evaluation analytics. Local evaluation means the server never sees an evaluation happen. We can count how many times a flag was created or modified; we can’t tell an operator “this flag was evaluated 10,000 times in the last hour with these distribution results.” Optional sampled reporting — sending some percentage of evaluations back — would close the gap.

Flag dependency tracking. If flag B’s evaluation depends on flag A’s result, that edge exists nowhere in the system. Change A, and B’s effective behavior changes with no signal to anyone. It’s a hard problem, and we’re not sure what the right solution is.

Bootstrapping from a file. Some environments restrict network access at startup. Loading initial flag state from a snapshot file exported from the console would let the SDK start without a connection — live updates would still need the WebSocket, but initial evaluation wouldn’t block on connectivity.