Killing the Server in the Read Path: Feature Flags as Static Files on a CDN
Most feature flag services ask you to accept the same quiet dependency: somewhere between your dashboard and your users sits a fleet of their servers, and your flags are only as available as that fleet. Streaming endpoints, evaluation APIs, edge workers running vendor code. When it degrades, your flags degrade with it, and a tool you adopted to make deploys safer becomes one more thing that can wake you up at 2 AM.
Switchbox makes a different trade. Its read path contains no server of mine at all: flag configs are static JSON files on a CDN. The API server exists only to handle writes from the dashboard; if it disappears, flags keep serving, because it was never in the path that serves them.
This post is the architecture: what the topology buys, how the writes stay consistent with the static files, and, just as important, what this design honestly costs. There are real trade-offs, and if you're evaluating a flag service you deserve to see them stated plainly rather than discover them in an incident review.
The Bet: Flags Are a CDN-Shaped Problem
Look at the actual data profile of feature flags:
- Tiny. A project's entire flag config, every flag, every targeting rule, every rollout percentage, fits in a few kilobytes of JSON.
- Read-heavy to an absurd degree. A config changes a few times a day at most. It gets read on a poll loop by every running instance of your application, forever. The read:write ratio isn't 100:1, it's millions to one.
- Tolerant of small staleness. A rollout going from 20% to 30% a few seconds later than you clicked the button changes nothing. Flags are not payments.
Small, immutable-ish, read-heavy, staleness-tolerant. That is precisely the workload CDNs were invented for. Serving it from a live application server, with a database behind it and a cache in front of it, means rebuilding a worse CDN out of infrastructure you now have to keep alive.
So Switchbox doesn't. On every flag mutation, the backend regenerates the affected environment's entire config as one JSON file and pushes it to object storage behind Cloudflare's edge. SDKs fetch that file directly and evaluate every rule locally, in process. The "flag service" your application talks to is a static file.
The Architecture
The two halves only touch at the storage bucket. The write path is a normal boring CRUD app: dashboard, API, Postgres. Every mutation that affects what an SDK should see, a toggle, a targeting rule change, a rollout adjustment, ends by rebuilding the full JSON for that environment and uploading it to R2 at a path derived from the environment's SDK key.
The read path is an HTTP GET. The SDK builds one URL from the SDK key, fetches it on a 30 second poll, caches the config in memory, and answers every flag check from that cache with pure local evaluation:
from switchbox import Switchboxclient = Switchbox(sdk_key="your-sdk-key")# No network call happens here. The rule walk, the deterministic# rollout bucketing, all of it runs locally against the cached config.if client.enabled("new_checkout", user={"user_id": "42"}):show_new_checkout()
Rollout bucketing is a SHA-256 hash of user_id:flag_key reduced mod 100, so the same user gets the same answer on every check, on every instance, in every language. The Python and JavaScript SDKs are pinned to identical behavior by a shared set of parity test vectors that both test suites run in CI, because a rollout that buckets differently across your backend and your frontend is worse than no rollout at all.
Both SDKs have zero runtime dependencies. Python is stdlib only; JavaScript uses only web APIs. That's not minimalism for its own sake: a library that sits in everyone's hot path should not bring a dependency tree along.
What the Topology Buys You
The interesting property is what the read path doesn't contain. Walk the failure modes:
| What breaks | What happens to flag reads |
|---|---|
| The Switchbox API is down | Nothing. It's not in the path. The dashboard is unavailable; reads don't notice. |
| Postgres is down | Nothing. The database serves writes, not reads. |
| A deploy of mine goes wrong | Nothing. Deploys touch the write path only. |
| Cloudflare's edge is down | SDKs keep serving the last cached config, and new processes fall back to code-level defaults. This is the real dependency, shared with half the internet. |
I can scale my entire backend to zero and every SDK in the world keeps working:
$ fly scale count 0 # kill every API instance$ curl https://cdn.switchbox.dev/<sdk_key>/flags.json200 OK # reads never noticed
I want to be precise about what kind of claim this is, because "highly available" is usually a promise about how well someone operates their servers. This isn't that. It's a topology fact: the availability of flag reads and the availability of my infrastructure are two unrelated numbers. I cannot page myself into your read path, because there is nothing of mine in it to page about.
To be fair to the incumbents: most mature flag SDKs also cache locally, so a vendor outage usually degrades to stale flags rather than failed checks. The difference is what sits behind that cache. For a streaming architecture it's a fleet of connection servers and evaluation infrastructure, and the moments that miss the cache, a fresh serverless instance, a new browser session, a container that just restarted, hit that live infrastructure every time. Here, the thing behind the cache is a file.
The Honest Bill
None of this is free. Removing the server from the read path is a trade, and here is what it costs.
Flags Are Up to Half a Minute Stale
SDKs poll every 30 seconds and the file is served with max-age=30. A toggle typically reaches clients within one poll interval; the worst case, an unlucky poll plus a just-cached response, is about a minute. That is the headline trade-off and there's no point dressing it up.
For flags specifically, I think it's the right trade. Gradual rollouts, beta cohorts, per-customer entitlements, A/B variants: none of these care about 30 seconds. The one case that genuinely does is the instant kill switch, where "off" must mean sub-second, globally. A poll-based CDN architecture cannot give you that, and I won't pretend otherwise. If sub-second revocation is a hard requirement, you need a streaming push architecture and you should accept the server fleet that comes with it, because that's what it's for.
There Are Now Two Copies of the Truth
The moment you serve reads from generated files, you have a distributed-systems problem: Postgres says one thing, and the JSON on the edge might say another. A publish can fail after the database commit. Two rapid edits can race and land on the CDN out of order. Wave this away and you'll eventually serve a config the dashboard says doesn't exist.
Switchbox handles it with two mechanisms, both boring on purpose:
- The database is the single source of truth, structurally. Every mutation runs through one shared code path: commit the change and its audit entry in one transaction, then publish the regenerated config. A failed publish is logged and alerted on but never fails the request, because the write already happened and the fix is to republish, not to pretend the write didn't occur.
- A reconciler makes divergence self-healing. A background loop sweeps every environment every ten minutes, builds the config the database says should be served, fetches what the CDN is actually serving, and republishes on any difference. Missed publish, out-of-order race, a bad write to the bucket: all of them converge within one sweep, without a human noticing.
So the consistency story is: usually instant, guaranteed within about ten minutes, and always converging toward Postgres. Eventual consistency with a short, bounded "eventual" is a perfectly good deal for this data. It would be a terrible deal for your billing system, which is why your billing system should not run on this architecture and your flags can.
Everything Resolves at Publish Time
There's no server evaluating requests, so there's nowhere for late-binding cleverness to live. Anything dynamic has to be resolved when the file is generated. Reusable segments get inlined into each flag's rules at publish. Value overrides collapse to one concrete value per environment. The SDK sees a flat, fully-resolved config and stays dumb.
That puts a ceiling on expressiveness. Rule expansion is capped so a config can't balloon into megabytes that every client re-downloads on every poll. And some features are structurally off the table: you can't target on server-side data the SDK doesn't have, and the service can't compute per-request exposure analytics for you, because it never sees a request. If you're measuring an A/B test, you instrument the exposure event in your own analytics. I'd argue that's where it belongs anyway, but it is work the streaming vendors do for you.
Your Targeting Rules Ship to the Client
The config is a file fetched with a key that, in a browser SDK, is visible to anyone who opens DevTools. Treat everything in it, flag names, targeting rules, rollout percentages, as readable by your users. Don't put secrets in flag values or personal data in rule lists. This is true of every client-side flag SDK ever shipped, but a static-file architecture makes it unambiguous, so it deserves a plain warning label.
The Invariants That Make It Hold
An architecture like this lives or dies on a few rules being unbreakable rather than usually-followed:
- Every mutation publishes. No exceptions. This is enforced by code structure, one shared mutation tail, not by developer discipline. The one bug class that would quietly kill this product is a write path that forgets to publish.
- Creation publishes too. A brand-new environment publishes its (empty) config immediately, so an SDK key copied seconds after creation never 404s.
- Key rotation dual-publishes. Rotating an SDK key keeps publishing to the old key's path for a 24 hour grace period, so deployed clients keep working while you roll out the new key. Revoking a compromised key skips the grace period and purges the old file, so a leaked key 404s within seconds.
- Read-path telemetry fails open. The edge worker that serves the file also records usage signals, and every one of those writes is fire-and-forget. The only failure that can fail a read is the storage read itself. Observability must never become the outage.
When You Should Not Pick This
Stated as plainly as I can:
- You need sub-second kill switches. Poll-based delivery can't do it. Pick a streaming architecture.
- You need server-side evaluation against data the client can't see, or vendor-computed exposure analytics for large-scale experimentation. That's an experimentation platform, and it legitimately needs servers.
- You have thousands of flags with enormous rule sets per environment. The whole-file model wants configs measured in kilobytes.
If none of those describe you, and for most teams shipping features behind flags they don't, then the servers in the mainstream read path aren't buying you anything. You're carrying their availability anyway.
The Economics Fall Out of the Architecture
One consequence worth naming, last and briefly. Serving a static file from a CDN costs a rounding error, so flag reads are something I never have to meter or throttle. Pricing by evaluation volume, the standard model in this market, exists because evaluations hit infrastructure that scales with them. Remove that infrastructure and the cost model that justified the pricing model goes with it. Cheap here isn't a discount; it's what the architecture makes the service cost.
Try It
The whole loop, dashboard to CDN to your code, takes about five minutes to see end to end:
pip install switchbox-flags
from switchbox import Switchboxclient = Switchbox(sdk_key="your-sdk-key-from-dashboard")if client.enabled("my_first_flag", user={"user_id": "42"}):print("served from the edge, evaluated locally")
Toggle the flag in the dashboard and watch the change arrive on the next poll. The quickstart walks the whole path, and everything in this post is inspectable from the outside: fetch your own flags.json and look at it. It's just a file. That's the point.