Skip to content

Cloudflare Workers SDK Reference

TL;DR: Install @featureflip/edge, create the client once per isolate, call await flags.load(ctx) in each request, then read flags synchronously with snapshot.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).

Terminal window
npm install @featureflip/edge

createEdgeClient(config) takes an EdgeConfig. Only sdkKey is required.

OptionTypeDefaultDescription
sdkKeystringrequiredA server SDK key. Sent as the Authorization header and never logged
baseUrlstring"https://eval.featureflip.io"Origin of the Evaluation API
ttlSecondsnumber60Age at which a served copy gets refreshed in the background
maxStaleSecondsnumber86400Age past which a copy is no longer served at all
loadTimeoutMsnumber1000How long a load with no usable copy waits for the origin before serving defaults
storeConfigStoreWorkers Cache APIWhere a fetched config is shared between isolates
wrappersstring[][]name/version tokens for libraries that wrap this SDK, sent after the SDK’s own identifier
flushBatchSizenumber100Events sent per POST /v1/sdk/events request
fetchtypeof fetchglobal fetchThe 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.

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.

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.

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');
}

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 });
MethodSignature
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.

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<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'
PropertyTypeDescription
valueTThe evaluated value
reasonEvaluationReasonWhy this value was served
variationKeystring | undefinedKey of the served variation
ruleIdstring | undefinedID of the matched targeting rule, on RuleMatch
prerequisiteKeystring | undefinedThe 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.

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 clientSideVisible field is left out. An Evaluation API that predates the field can’t vouch for any flag, so against one, bootstrapValues returns an empty object.
  • Failed evaluations are omitted. A flag that resolves with reason Error gets no key at all, so the browser SDK falls back to its own default for it. A variation that really does serve null is 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.

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.

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.

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(): 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.

load() also works with no argument, in code that has no ExecutionContext to hand over. Two things change:

  1. The SDK schedules no flush. Await flags.flush() yourself before returning the response.
  2. 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));

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.

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'.