Polling vs Streaming Flag Synchronization

This guide is part of the Backend Evaluation & Server-Side SDKs series. Once you commit to local, in-process evaluation, the only open question is how the local rule set stays fresh: a server-side SDK either polls the control plane on an interval or holds a streaming connection that pushes changes as they happen. That single choice sets your propagation latency, your connection budget, and how fast a kill switch actually reaches every node.

It is worth being precise about what “fresh” means here, because the word hides a real distributed-systems problem. The control plane holds the authoritative flag configuration; every replica holds a copy. Freshness is the lag between a write landing in the control plane and that write becoming visible in each replica’s local copy — and because there is no single lag but one per replica, the property you actually care about is convergence: the point at which every node in the fleet agrees on the same variant. Polling and streaming are simply two mechanisms for driving that convergence, and they trade the same three currencies — latency, connection cost, and reconnection complexity — in opposite directions. Pick the transport by working backward from the tightest convergence guarantee you have promised the business, usually the one attached to emergency rollback rather than to routine rollout, since rollback is the case where a slow transport turns a minor incident into a prolonged one.

Problem Framing: When the Transport Choice Matters

Transport is invisible until it isn’t. Polling on a 60-second interval means a flag flip — including an emergency rollback — can take up to a full minute to reach a given replica, and a fleet of 500 pods polling independently produces a steady drumbeat of requests against the control plane. Streaming collapses propagation to sub-second but holds a long-lived connection per process and demands disciplined reconnection logic.

The failure this framing guards against is the one that only shows up during an incident. In steady state, both transports look identical: flags change on a leisurely cadence, every replica catches up within a comfortable window, and nobody measures the difference. The gap appears the moment you flip a kill switch to stop a bad rollout and then watch a dashboard that still shows a fraction of traffic hitting the broken path for the next forty seconds — because that fraction is served by replicas whose next poll has not fired yet. A propagation budget that reads “rollback visible within 2s” is not pedantry; it is the number that decides whether your worst-case blast radius is measured in requests or in minutes. Write it down before you pick a transport, because the transport is how you meet it.

There is also a subtler cost that rarely makes it into the decision: operator confidence. When propagation is slow and uneven, the person driving a rollout cannot trust the dashboard — they flip a flag, see partial effect, and cannot tell whether the change is wrong or merely still propagating. That ambiguity slows every rollout decision and pushes teams toward over-cautious, manual, staged changes. Fast and observable convergence is what lets an operator flip a flag and believe the resulting metric, which is the real productivity payoff of getting the transport right.

This guide covers the decision and the wiring for both transports under OpenFeature. It does not cover the cache topology behind the SDK (see distributed caching for flag evaluations) or rule compilation (see optimizing rule engine performance).

Polling versus streaming propagation timelines Polling resolves a flag change only at the next interval tick, leaving a staleness window; streaming pushes the change immediately. Polling change applied at next tick — staleness window Streaming change pushed immediately — sub-second propagation
Polling applies a change only at the next interval tick; streaming pushes it the moment the control plane records it.

Prerequisites

What a sync transport setup depends on Five prerequisites: an SDK provider that supports both transports, egress to the control plane, a readiness probe tied to connection state, flag metadata, and an agreed propagation budget. Both-transport provider (flagd) Egress to control plane Readiness probe on connect state Flag metadata owner / expiry Latency budget e.g. 2 s rollback
The budget and the connection-state probe are what make the transport choice measurable rather than a guess.

Core Concept & Architecture

Both transports converge on the same local state — a compiled rule set the rule engine reads in-process. They differ only in how that state is refreshed. The decision matrix:

Dimension Polling Streaming (SSE)
Propagation latency Up to one interval Sub-second
Control-plane load Requests × replicas ÷ interval One open connection per replica
Resilience to blips Trivially stateless Needs reconnect + resync
Firewall friendliness High (plain HTTP) Lower (long-lived connection)
Best for Large fleets, relaxed SLAs Kill switches, fast canaries

A robust setup is rarely pure: stream for low-latency propagation, and keep a slow background poll as a safety net that heals missed events during a reconnect gap.

Read the matrix as a set of trade curves rather than a verdict. Propagation latency and control-plane load pull against each other on the polling side — the only way polling gets faster is a tighter interval, and a tighter interval is more requests per second across the whole fleet, so polling has no free lunch: you buy latency with load. Streaming breaks that coupling. Its latency is fixed at roughly the network round trip regardless of fleet size, and its cost is a flat count of open connections rather than a request rate, so shrinking latency to zero costs nothing extra. What streaming buys with that decoupling is complexity: a persistent connection is a piece of state that can rot, and every dropped connection is a small correctness hazard until the resync completes. Polling has no such hazard because it carries no state between requests — each poll is a fresh, complete question, which is exactly why it survives flaky networks and aggressive proxies that streaming does not.

The firewall row deserves more weight than it usually gets in a design review. A plain HTTP poll looks like every other request your service already makes, so it passes through corporate proxies, service meshes, and egress filters without special dispensation. A long-lived gRPC or SSE connection is a different animal: some load balancers cap idle connection lifetime, some proxies buffer streamed responses until the buffer fills (destroying the whole point of streaming), and some egress policies simply do not permit persistent outbound connections. If you cannot control the network path end to end — common in regulated environments and in multi-tenant platforms — polling is often the pragmatic default not because it is better but because it is the one that reliably connects.

Polling versus streaming across the decision dimensions A comparison matrix: propagation latency, control-plane load, resilience, firewall friendliness, and best fit, contrasting polling against streaming. Dimension Polling Streaming Propagation latency up to 1 interval sub-second Control-plane load N ÷ interval N connections Resilience to blips stateless needs resync Firewall friendliness high lower Best for large fleets, relaxed SLA kill switches, canaries
The two transports converge on the same local state; they differ only in latency, connection cost, and reconnection discipline.

Step-by-Step Implementation

The production shape is a hybrid: streaming carries changes with sub-second latency, a slow background poll heals anything missed during a reconnect gap, and a last-known-good cache keeps evaluation alive if both fail.

Streaming-primary with a polling backstop The control plane pushes changes over a primary stream to the local rule set; a slow background poll runs alongside to heal missed events, and a last-known-good cache backs both. Control plane flag config Local rule set in-process LKG fallback stream · sub-second poll backstop · heals gaps
Streaming is primary; the poll backstop and the last-known-good cache turn a dropped connection into degraded freshness, never an outage.

Step 1 — Configure streaming as the primary transport

Point the provider at the control plane and select the streaming resolver so changes arrive over a persistent connection.

# provider-sync.yaml — flagd sync configuration
sync:
  selector: "core"
  provider: streaming          # primary: push-based updates
  uri: "grpc://flagd.internal:8013"
  poll_interval_ms: 30000       # fallback poll heals missed events
cache:
  local_ttl: 0                  # 0 = trust the stream; no independent expiry
  fallback: last_known_good

Pitfall: setting a nonzero local_ttl alongside streaming creates two sources of truth — the stream says “current” while the TTL silently expires entries. Let the stream own freshness and use the poll only as a backstop.

The selector: "core" line matters more than it looks. A selector scopes the stream to a subset of flags — here the core namespace — so a replica subscribes only to the flags it actually evaluates rather than the entire catalog. On a large deployment where different services own disjoint flag sets, a broad selector means every push fans out to every replica whether or not the change is relevant, inflating both control-plane egress and the per-replica cost of parsing updates it will ignore. Scope the selector to what the service reads and the stream stays quiet except when something the service cares about actually moves. The poll_interval_ms of 30 seconds is deliberately slack: because the stream is primary, the poll exists only to catch the rare event that slipped through a reconnect gap, so a long interval keeps its steady-state load near zero while still bounding worst-case staleness at thirty seconds if the stream dies quietly and the connection metric fails to fire.

Step 2 — Wire reconnection with backoff and resync

A streaming connection will drop. On reconnect, do a full resync rather than assuming you only missed the latest event.

import { OpenFeature } from '@openfeature/server-sdk';

provider.on('reconnecting', () => metrics.increment('flag.stream.reconnect'));
provider.on('ready', async () => {
  await provider.resyncFlags();           // pull full state, not just a delta
  metrics.gauge('flag.stream.connected', 1);
});
provider.on('error', () => metrics.gauge('flag.stream.connected', 0));

Pitfall: applying only the delta after a gap leaves a node permanently stale for any flag changed during the disconnect. Always resync the full set. See exponential backoff for SDK reconnection for the backoff curve.

The reason a full resync is non-negotiable is that a streaming protocol gives you no reliable way to know what you missed. Delta streams typically carry the change, not a running sequence number you can compare against — and even when they do carry a cursor, a cursor only tells you that you fell behind, not the contents of the events between your last-seen position and now. Once the connection is back, the cheapest correct move is to throw away the assumption of continuity and pull the entire current state, which is idempotent by construction: re-applying flags you already had costs nothing, while missing one costs you a silently wrong evaluation until the next change happens to touch it. The resync is also where the backoff curve earns its keep. If the control plane is the thing that fell over, every replica reconnecting on the same tight schedule produces a synchronized reconnect storm precisely when the control plane can least absorb it — jittered exponential backoff spreads those resyncs across a widening window so recovery is gradual rather than a second outage. Emit a metric on every resync, not just on connect, so a fleet that is quietly flapping shows up as a rising resync rate long before it becomes a stale-flag incident.

Step 3 — Fall back to polling where streaming is impractical

Behind strict proxies or in serverless runtimes that recycle connections, a short poll is more reliable than a stream that constantly re-establishes.

# serverless-sync.yaml — polling-only profile
sync:
  provider: polling
  uri: "https://flagd.internal/flags"
  poll_interval_ms: 5000        # tighten interval to shrink the staleness window
cache:
  local_ttl: 5s
  fallback: last_known_good

Pitfall: every replica polling on the same fixed interval synchronizes into a thundering herd. Add jitter (±20%) to the interval so requests spread across the window.

Serverless is the case where polling stops being a fallback and becomes the correct primary. A function that is frozen between invocations cannot hold a stream — the runtime suspends the process, the connection dies, and you pay a full reconnect-and-resync on the next cold start, which adds latency to exactly the request you least want to slow down. A short poll fired at the top of the handler, or a cached snapshot refreshed on a TTL, fits the execution model: it is stateless, it completes within the invocation, and it degrades gracefully to the last-known-good copy if the control plane is briefly unreachable. Tighten the interval here with eyes open — a 5-second interval means a serverless fleet can lag a rollout by up to five seconds, which is fine for a gradual ramp and wrong for a kill switch, so pair aggressive serverless polling with a separate, faster emergency path if instant rollback is a hard requirement. The jitter matters even more in serverless than in a long-lived fleet, because autoscaling tends to spin up cohorts of identical instances simultaneously, and without jitter their first polls land in the same millisecond and repeat on the same beat forever.

Also make the poll’s failure mode explicit. A poll that returns a 500 or times out must not silently overwrite good local state with nothing — the client should keep serving the last successful snapshot and count the failure, so a control-plane wobble degrades freshness rather than blanking flags to their code defaults mid-request. The difference between “the flag held its last value for thirty seconds” and “the flag reverted to false because a poll failed” is the difference between a non-event and an incident.

Verification & Testing

Prove propagation latency rather than assuming it. Flip a canary flag and measure the time until every replica reports the new variant.

# Flip the flag, then poll each replica's evaluation endpoint until consistent
flagctl set web.dashboard.new-nav --variant on
for host in $(cat replicas.txt); do
  curl -s "$host/debug/flags/web.dashboard.new-nav" | jq -r '.variant'
done | sort -u   # expect a single line "on" within your latency budget

For streaming, assert reconnect behavior by killing the connection (block the port for 5s) and confirming a full resync fires on recovery.

The subtle part of this test is what you measure, not that you measure. A single averaged propagation number hides the failure that hurts: the tail. If 499 replicas converge in 200ms and one converges in 40 seconds because it was mid-reconnect, the mean looks excellent while your worst-case blast radius is still forty seconds wide. Assert against the maximum across replicas, not the mean, and run the flip repeatedly — a dozen times across a normal deploy cycle — so you catch the convergence that only misbehaves when a replica happens to be restarting. The convergence assertion should also be run during a simulated control-plane disruption, not only in calm conditions, because the transport’s real job is to keep flags correct while the network is misbehaving, and that is the one scenario a happy-path test never exercises. Wire the flip-and-converge loop into a synthetic check that runs continuously against a dedicated canary flag; a slow drift in convergence time is an early warning that connections are flapping or the control plane is saturating, and catching it as a trend beats catching it as a paging incident. Keep that canary flag inert — a boolean nothing reads in production logic — so the test can flip it as often as it likes without touching real behavior.

Propagation test: flip once, converge everywhere Flip a canary flag, then query every replica's debug endpoint; the test passes when all replicas report the new variant within the propagation budget. flip canary flag variant on replica A → on replica B → on replica C → on sort -u = one line within budget = PASS
Convergence is the assertion: every replica must report the flipped variant within the agreed budget, collapsing to a single distinct value.

Troubleshooting & FAQ

Why is a flag change not reaching some replicas?

On streaming, those replicas almost certainly lost the connection and resynced from a stale snapshot, or never reconnected — check your flag.stream.connected gauge. On polling, the change simply hasn’t reached the next tick yet, or the replica’s poll is failing silently; verify the poll response status.

How small can I make the polling interval?

Small enough to meet your propagation budget, large enough that replicas ÷ interval stays within the control plane’s request budget. Below ~1s, the request volume usually justifies switching to streaming instead.

Do I still need a cache if I’m streaming?

Yes — the local rule set the stream maintains is the cache. The stream keeps it fresh; the last_known_good fallback keeps evaluation working through a control-plane outage. Caching strategy is covered in distributed caching for flag evaluations.

Can I run streaming and polling at the same time?

Yes, and for most production fleets you should. Run streaming as the primary transport for sub-second propagation and keep a slow background poll — a 30-second interval is typical — as a backstop that heals any event missed during a reconnect gap. Give the stream ownership of freshness by disabling independent cache expiry, so the two mechanisms cannot disagree about what is current. The poll is insurance, not a second source of truth.

How do I know if my stream is silently dead?

A dropped stream is dangerous precisely when it fails quietly — the connection object still exists but no events arrive, so evaluations keep returning stale values with no error. Guard against it with a heartbeat: expect a keepalive frame or a periodic no-op event on an interval, and treat its absence as a disconnect that triggers reconnect-and-resync. Expose a connected gauge and a resync-rate metric so a fleet that is quietly flapping shows up on a dashboard before it becomes a stale-flag incident.

Does streaming guarantee ordering of flag updates?

Within a single healthy connection, updates arrive in the order the control plane emitted them, so ordering holds. Across a reconnect it does not — you may miss events entirely, which is why recovery must pull the full current state rather than replaying a delta. Design your flag logic to be order-independent where possible and never rely on observing two changes to the same flag in a specific sequence; treat each update as “here is the current value,” not “here is the next step.”

Should I measure propagation latency as a mean or a maximum?

Measure the maximum across the fleet. A mean hides the tail, and the tail is the risk: if one replica out of five hundred takes forty seconds to converge because it was mid-reconnect, the average still looks excellent while your worst-case blast radius is forty seconds wide. Assert your propagation budget against the slowest replica, and run the flip-and-converge test repeatedly across a deploy cycle so you catch the convergence that only misbehaves during a restart.

Performance & Scale Considerations

Budget propagation as part of your rollout SLA, not as an afterthought. For a fleet of N replicas, polling costs roughly N ÷ interval requests per second against the control plane; streaming costs N persistent connections plus a burst of resyncs whenever the control plane redeploys. Above a few hundred replicas, prefer streaming with a generous fallback poll and stagger replica restarts so resyncs don’t arrive as one spike. Keep the evaluation path itself local so transport hiccups never add latency to a request — that separation is the whole point of backend evaluation.

The resync burst is the scaling limit most teams hit first, and it is worth sizing concretely. A 500-replica fleet holding 500 idle streams costs the control plane almost nothing in steady state — idle connections are cheap. But roll the control plane, or roll the fleet, and all 500 replicas resync within the same short window: 500 full state pulls, each transferring the entire flag set the selector scopes to, arriving as one spike. If the flag payload is 200KB, that is 100MB of egress compressed into a few seconds, plus the CPU to serialize it 500 times. This is why staggered restarts and connection draining matter at scale — a rolling deploy that replaces ten pods at a time turns one 500-wide spike into fifty small ripples the control plane barely notices. The same logic applies to the control plane’s own deploys: drain streams gracefully so clients reconnect on a backoff curve rather than all at once when the old process dies.

Memory and connection limits set the other ceiling. Every open stream consumes a file descriptor and a slice of buffer on both ends, so a control plane fronting tens of thousands of replicas needs its connection limits, ephemeral port ranges, and load-balancer keepalive settings tuned deliberately — the defaults on most proxies assume short requests, not a standing army of persistent connections. When you cross into that territory, a tier of relay or agent nodes that fan a single upstream stream out to many local replicas keeps the connection count against the true control plane flat; the SSE tuning guide covers the keepalive and buffering knobs that keep those long-lived connections from being silently reaped.

Edge cases worth planning for

A few situations sit outside the happy path and reliably cause incidents when nobody has thought about them in advance:

Control-plane cost of each transport at fleet scale Polling cost is the replica count divided by the interval as requests per second, which grows as the interval shrinks; streaming cost is a fixed count of persistent connections plus resync bursts on redeploy. Polling N ÷ interval req/s tighter interval → more load 500 pods / 5 s = 100 req/s Streaming N persistent conns flat, independent of latency + resync burst on redeploy
Polling load scales inversely with the interval; streaming load is a flat connection count, which is why large fleets favor streaming with a slow fallback poll.