Cloudflare Workers SDK Reference
TL;DR: Install
@featureflip/edge, create the client once per isolate, callawait flags.load(ctx)in each request, then read flags synchronously withsnapshot.boolVariation('flag-key', { user_id: 'u-123' }, false).
@featureflip/edge evaluates feature flags inside a Cloudflare Worker. It fetches your environment’s flag config from the Evaluation API, keeps a copy in the isolate’s memory and in the Workers Cache API, and runs every evaluation against that copy. A request that finds a warm copy reads all of its flags without a network call. The evaluation engine is the one @featureflip/js runs, so targeting rules, percentage rollouts and prerequisites resolve to the same values and reasons here as in the Node.js SDK.
Cloudflare Workers is the only supported runtime. That includes Pages Functions, which run on Workers.
The server SDKs hold a streaming connection open. A Worker can’t keep one alive between requests, so this SDK refreshes its copy whenever a request finds it older than ttlSeconds. Flag changes reach edge traffic within ttlSeconds (60s by default).
Installation
Section titled “Installation”npm install @featureflip/edgeConfiguration
Section titled “Configuration”createEdgeClient(config) takes an EdgeConfig. Only sdkKey is required.
| Option | Type | Default | Description |
|---|---|---|---|
sdkKey | string | required | A server SDK key. Sent as the Authorization header and never logged |
baseUrl | string | "https://eval.featureflip.io" | Origin of the Evaluation API |
ttlSeconds | number | 60 | Age at which a served copy gets refreshed in the background |
maxStaleSeconds | number | 86400 | Age past which a copy is no longer served at all |
loadTimeoutMs | number | 1000 | How long a load with no usable copy waits for the origin before serving defaults |
store | ConfigStore | Workers Cache API | Where a fetched config is shared between isolates |
wrappers | string[] | [] | name/version tokens for libraries that wrap this SDK, sent after the SDK’s own identifier |
flushBatchSize | number | 100 | Events sent per POST /v1/sdk/events request |
fetch | typeof fetch | global fetch | The fetch implementation, mostly useful in tests |
createEdgeClient throws only when sdkKey is missing or empty. It makes no request, so calling it inside your handler costs nothing after the first time.
Creating the client
Section titled “Creating the client”import { createEdgeClient, type EdgeClient } from '@featureflip/edge';
interface Env { FEATUREFLIP_SERVER_KEY: string;}
let flags: EdgeClient | undefined;
export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { flags ??= createEdgeClient({ sdkKey: env.FEATUREFLIP_SERVER_KEY }); const snapshot = await flags.load(ctx); // ... },};Keep the client in a module-scope variable and create it on the first request, as above. Workers don’t allow I/O at module scope and your key arrives on env, which only exists inside the handler. The isolate then reuses that one client, and its memory copy of the config, for every request it serves. A client created fresh per request would start empty each time and fall back to the Cache API on every load.
Loading a snapshot
Section titled “Loading a snapshot”load(ctx)
Section titled “load(ctx)”load(ctx?: WaitUntilContext): Promise<EdgeSnapshot>Call it once per request. It resolves to an EdgeSnapshot, an immutable view of one flag config, and every variation call on the snapshot is synchronous. load never rejects. With no usable config it resolves to a snapshot that serves your defaults.
ctx is anything with a waitUntil(promise) method: the Worker’s ExecutionContext, or the context argument of a Pages Function. The SDK hands it two pieces of background work:
- a config refresh, when the copy it served is older than
ttlSeconds - a flush of queued evaluation events, which sends only when a batch is full or 10 seconds have passed since the last send
Both keep running after your response has been sent.
snapshot.ready and snapshot.version
Section titled “snapshot.ready and snapshot.version”ready is false only when no config was available and the snapshot is serving defaults. version is the environment config version the snapshot was built from, and undefined on that empty snapshot.
const snapshot = await flags.load(ctx);if (!snapshot.ready) { console.log('serving defaults: no flag config loaded yet');}Evaluating flags
Section titled “Evaluating flags”Each accessor takes a flag key, an evaluation context and a default value. The context is a plain object of attributes. user_id (or its userId alias) is the key percentage rollouts bucket on, and it’s the user an evaluation event is attributed to.
const enabled = snapshot.boolVariation('new-checkout', { user_id: 'u-123' }, false);const copy = snapshot.stringVariation('checkout-copy', { user_id: 'u-123' }, 'control');const limit = snapshot.numberVariation('cart-limit', { user_id: 'u-123' }, 20);const banner = snapshot.jsonVariation('promo-banner', { user_id: 'u-123' }, { show: false });| Method | Signature |
|---|---|
boolVariation | (key: string, context: EvaluationContext, defaultValue: boolean) => boolean |
stringVariation | (key: string, context: EvaluationContext, defaultValue: string) => string |
numberVariation | (key: string, context: EvaluationContext, defaultValue: number) => number |
jsonVariation | <T>(key: string, context: EvaluationContext, defaultValue: T) => T |
You get the default back when the flag doesn’t exist, when no config is loaded, or when evaluation fails. The three typed accessors also check the served value’s type. Ask boolVariation for a flag that serves a string and it returns your default with reason Error. jsonVariation returns the served value whatever its JSON type.
Visitor identity
Section titled “Visitor identity”The SDK never generates a key for a visitor. Without a user_id, a context is skipped by every percentage rollout and falls through to the next matching rule or the default variation, so your anonymous traffic never enters the split. For signed-in users, pass the account ID. For anonymous traffic, set a long-lived cookie holding a random ID the first time you see a visitor, and read it back on later requests. The quickstart has a full example.
variationDetail()
Section titled “variationDetail()”variationDetail<T>(key: string, context: EvaluationContext, defaultValue: T): EvaluationDetail<T>Returns the value along with the reason it was served. Like jsonVariation, it doesn’t check the value’s type.
const detail = snapshot.variationDetail('checkout-copy', { user_id: 'u-123' }, 'control');
detail.value; // 'variant-b'detail.reason; // 'RuleMatch'detail.variationKey; // 'b'detail.ruleId; // 'rule-abc'| Property | Type | Description |
|---|---|---|
value | T | The evaluated value |
reason | EvaluationReason | Why this value was served |
variationKey | string | undefined | Key of the served variation |
ruleId | string | undefined | ID of the matched targeting rule, on RuleMatch |
prerequisiteKey | string | undefined | The prerequisite that failed, on PrerequisiteFailed |
EvaluationReason is one of 'RuleMatch', 'Fallthrough', 'FlagDisabled', 'FlagNotFound', 'PrerequisiteFailed' or 'Error'.
A read of a key the loaded configuration doesn’t contain logs a warning naming the key, once per key per isolate, so a typo in a flag key shows up in your Worker logs. A snapshot with no configuration (ready === false) doesn’t log it.
Bootstrap values for the browser
Section titled “Bootstrap values for the browser”bootstrapValues()
Section titled “bootstrapValues()”bootstrapValues(context: EvaluationContext): Record<string, unknown>Returns the served value of every client-visible flag for one context, keyed by flag key. Hand the object to the bootstrap option of @featureflip/browser or the React provider and the page’s first render already has real values. The bootstrap guide shows a Worker doing this end to end.
const context = { user_id: visitorId };const bootstrap = snapshot.bootstrapValues(context);// { 'new-checkout': true, 'checkout-copy': 'variant-b' }- Client-visible flags only. A flag goes in only when the server marks it
clientSideVisible: true. Server-only flags still count when a client-visible flag’s prerequisites resolve, but their own values never appear. - Fail-closed when the field is missing. A flag whose config has no
clientSideVisiblefield is left out. An Evaluation API that predates the field can’t vouch for any flag, so against one,bootstrapValuesreturns an empty object. - Failed evaluations are omitted. A flag that resolves with reason
Errorgets no key at all, so the browser SDK falls back to its own default for it. A variation that really does servenullis still included. - No events. These values aren’t reads by your Worker’s code, so they aren’t reported. The browser SDK reports its own reads.
Freshness
Section titled “Freshness”The SDK keeps two copies of the config. One lives in the isolate’s memory. The other sits in the Workers Cache API, keyed by a SHA-256 hash of the base URL and SDK key, where other isolates in the same Cloudflare data center can pick it up. The raw key never appears in the cache key.
On each load, the client serves its memory copy if that copy is younger than maxStaleSeconds. Otherwise it looks in the cache. A copy older than ttlSeconds is still served, and the same load starts a background refresh, one per isolate however many requests arrive. Busy traffic sees a flag change within ttlSeconds (60s by default). After a quiet spell the first request in can get the older copy once, since the refresh it triggered is still running.
A copy older than maxStaleSeconds (one day by default) is never served. If the Evaluation API stays unreachable that long, the client falls back to defaults.
After a failed config fetch (an error, a non-2xx status, a malformed body or a timeout), the client waits 15 seconds before it calls the origin again, and keeps serving any copy it has. A 401 or 403 gets its own warning, SDK key rejected, because a wrong or revoked key won’t recover by itself.
The Cache API is local to each data center, so each one fetches its own copy. Cloudflare documents that Cache API writes have no effect in the dashboard editor and Playground previews, and that the API isn’t available to Workers behind Cloudflare Access. In those places each isolate relies on its memory copy alone.
Cold start
Section titled “Cold start”When neither memory nor the cache has a usable copy, load waits up to loadTimeoutMs (1000 ms by default) for the origin. If the config arrives in time, the request evaluates against it. If it doesn’t, load resolves a snapshot with ready === false that serves your defaults, logs no config within 1000ms, and lets the fetch carry on in the background to fill the cache for the next request.
During the 15-second backoff after a failure, a cold load doesn’t wait at all. It serves defaults immediately.
Events
Section titled “Events”Each variation call queues an evaluation event, and bootstrapValues queues none. Events go to POST /v1/sdk/events in batches of flushBatchSize.
As load(ctx) resolves, it hands waitUntil a flush, which runs once your handler’s code after await load(ctx) yields and sends only if a full batch is waiting or 10 seconds have passed since the last send. If neither holds, the events wait. So an isolate’s per-request flushes send at most once every 10 seconds, sooner only when a full batch is waiting, however many requests it serves. One send is normally a single POST, and up to 5 when a backlog has built up.
Delivery is best effort and never delays or fails a flag read. Events live in memory. An evicted isolate loses its queue. A 429, a 5xx or a network error puts the failed batch back and stops all sending for 30 seconds. Once a send has gone 30 seconds without an answer, the SDK gives up on it and queues its events again. The next flush does that, or a flush() already waiting on the send does it itself. A send that did land, just late, can show up twice.
flush()
Section titled “flush()”flush(): Promise<void>Sends queued events now, without waiting out the 10 seconds. It never rejects. One call makes at most 5 requests (500 events at the default flushBatchSize), which keeps a backlog inside the 50 subrequests a Workers Free plan invocation gets, and anything left over goes with later flushes. Within 30 seconds of a failed send it returns without sending. If a send is already in flight it waits for that one, but no longer than until the send is 30 seconds old.
With ctx you rarely need it. Call it when there is no ctx, or when events from variation calls made after a later await should go out before the response does.
Running without ctx
Section titled “Running without ctx”load() also works with no argument, in code that has no ExecutionContext to hand over. Two things change:
- The SDK schedules no flush. Await
flags.flush()yourself before returning the response. - The background refresh is no longer tied to the request, so the runtime may cut it off when the request ends. The client keeps serving its current copy and tries again on a later load.
The first load() without ctx logs a one-time warning saying so.
const snapshot = await flags.load();const enabled = snapshot.boolVariation('new-checkout', context, false);await flags.flush();return new Response(String(enabled));Custom config store
Section titled “Custom config store”store replaces the Cache API with your own storage, such as a Workers KV namespace.
interface StoredConfig { data: GetFlagsResponse; // the GET /v1/sdk/flags body, stored and returned unchanged fetchedAt: number; // epoch milliseconds}
interface ConfigStore { get(): Promise<StoredConfig | undefined>; put(config: StoredConfig, maxStaleSeconds: number): Promise<void>;}Neither method should throw. Treat a read failure as a miss and a write failure as a no-op. The client checks fetchedAt against maxStaleSeconds itself and ignores a stored copy it can’t use.
Logging
Section titled “Logging”The SDK logs through console.warn, which shows up in Workers Logs and wrangler tail. Its own messages start with [featureflip-edge]. Watch for SDK key rejected, config refresh failed, no config within ...ms, failed to send ... analytics event(s) and running without waitUntil.
A few messages come from the evaluation core this SDK shares with @featureflip/js, and those start with [featureflip]. That prefix marks a batch of events the endpoint rejected for good, an event queue full enough to drop its oldest events, and a wrappers token that was ignored as invalid. Search for both prefixes.
type EvaluationContext = Record<string, unknown>;
interface EvaluationDetail<T = unknown> { value: T; variationKey?: string; reason: EvaluationReason; ruleId?: string; prerequisiteKey?: string;}
interface EdgeClient { load(ctx?: WaitUntilContext): Promise<EdgeSnapshot>; flush(): Promise<void>;}
interface WaitUntilContext { waitUntil(promise: Promise<unknown>): void;}EdgeConfig, EdgeSnapshot, ConfigStore, StoredConfig, GetFlagsResponse, EvaluationReason and FlagType are exported as types too, so a custom store can import type { GetFlagsResponse } from '@featureflip/edge'.
See also
Section titled “See also”- Cloudflare Workers quickstart for a first flag in a Worker
- Bootstrap client-side flags to hand the Worker’s values to the browser SDK
- Node.js SDK reference for long-running servers with a streaming connection
- Reliability and resilience on how the SDKs behave when Featureflip is slow or down
- Flags endpoint, the
GET /v1/sdk/flagspayload this SDK caches - SDK overview to compare server-side and client-side SDKs