TypeScript SDK
Read flags in browsers, on Node and in Cloudflare Workers and Vercel Edge Functions, with one package and no dependencies.
@featflags/js evaluates flags where your code runs: in the browser, on a Node server, or at the edge. It keeps a copy of your environment’s flags, so an evaluation is a function call, never a network request, and your users’ attributes never leave the process. It has no dependencies and weighs about 11 KB gzipped.
Using React? @featflags/react adds a provider and hooks on top of this package, and @featflags/react-native does the same for apps.
Install
npm install @featflags/js
It’s published on npm as @featflags/js, as an ES module with its own types.
Create a client
Create one client and keep it for the life of the page or process:
import { FlagClient } from '@featflags/js'
const flags = new FlagClient({
key: 'pk_…',
cdnUrl: 'https://flags.featflags.io',
eventsUrl: 'https://events.featflags.io',
context: { kind: 'user', key: user.id, plan: user.plan },
})
await flags.waitForReady() // optional: at most 2 s
if (flags.bool('new-checkout', false)) showNewCheckout()
In a browser, use the environment’s client key (pk_…). On a server, use a server key (sk_…), which reaches every flag; see On a server. Keys explains the difference.
waitForReady() resolves once the first fetch has answered and the context’s list shards have loaded, or after readyTimeout. You don’t have to wait: before that, evaluations use the flags saved by the previous page load, the bootstrap document, or your fallbacks.
| Option | Default | What it does |
|---|---|---|
key |
required | The environment’s client key (pk_…) or server key (sk_…). |
cdnUrl |
required | Where flags are read from: https://flags.featflags.io. |
eventsUrl |
none | Where usage counts, experiment exposures and track() events go: https://events.featflags.io. Without it, nothing is sent, and flag pages show no evaluations. |
context |
none | The user (or other context) to evaluate for. A user without a key gets an anonymous one. |
membershipTokens |
none | Tokens for private segments, from your backend. |
experimentKey |
none | The environment’s experiment key. Public if it’s in a browser bundle. |
experimentSeeds |
none | The context’s experiment seeds, from your backend. |
bootstrap |
none | Flags to evaluate before, or instead of, the first fetch. |
persist |
true |
Keep the last flags in storage and start the next page load from them. |
storage |
localStorage in browsers |
Where persist keeps them. Elsewhere, nowhere unless you pass one. |
anonymousKeyStorage |
localStorage where there is one |
Where an anonymous user’s key is kept, so they stay in their rollouts across visits. |
readyTimeout |
2,000 ms | The longest waitForReady() waits. |
pollInterval |
5 s for server keys, 60 s for client keys | How often it checks for changes, ±10%. 0 turns polling off. |
flushInterval |
60 s | How often it sends counts, plus a random offset of up to one interval the first time. |
membershipCache |
1,024 shards (about 2 MB) | List shards kept for contexts passed to evaluations. |
membershipTimeout |
500 ms | How long an …Async evaluation waits, in all, for list shards it doesn’t have. |
timers |
true |
false at the edge; see Edge runtimes. |
hooks |
none | Hooks run around every evaluation and identify. |
onExposure |
none | Called the first time each user sees each variant of a flag, for your own analytics. |
onChange |
none | Called when newer flags load. |
fetch, now |
the platform’s | Your own fetch, and a clock for rollout steps and date conditions. |
Evaluate flags
Each typed method takes the flag key and a fallback, and returns the flag’s value for the client’s context:
flags.bool('new-checkout', false)
flags.string('checkout-copy', 'Buy now')
flags.number('max-items', 10)
flags.json('pricing', { tiers: [] })
You get the fallback when the flag doesn’t exist (or is archived), the context is invalid, or the value has another type than you asked for. The …Detail methods (boolDetail, stringDetail, numberDetail, and detail for any JSON) also say why:
const d = flags.boolDetail('new-checkout', false)
// { value: true, variant: 'on', reason: 'rule', ruleId: 'r1' }
reason is one of disabled, prerequisite_failed (with prerequisite), target, rule (with ruleId), default, fallback, not_found, type_mismatch, error or membership_pending; evaluation reasons explains each. An error field names a problem that isn’t evaluation’s, such as a page origin the environment doesn’t allow.
Every method takes an optional context last, which replaces the client’s for that one call.
Contexts
A context says who or what you’re evaluating for. A single context has a kind (default user), a key, and any attributes your rules use:
await flags.identify({ kind: 'user', key: user.id, email: user.email, plan: 'pro', groups: ['beta'] })
A multi-context evaluates for several kinds at once, so one rule can target the user and another their organisation:
await flags.identify({ kind: 'multi', user: { key: user.id, plan: 'pro' }, org: { key: org.id, plan: 'team' } })
identify changes the client’s context, for example when someone signs in. It resolves once the new context’s list shards have loaded, and evaluations work straight away. A user context without a key gets an anonymous key, kept in anonymousKeyStorage, so a signed-out visitor stays in the same rollout bucket across page loads. See contexts for the rules.
Lists and private segments
Targets and segments can hold lists of up to millions of keys. The client fetches only the small shard each context falls in, once per kind, when you identify it. Until a shard arrives, a list condition on it doesn’t match and the reason is membership_pending; identify and waitForReady resolve once it’s there.
A segment marked private can’t be read without a token from your backend, so nobody can list who’s in it. Mint the token on your server with a server SDK (membership_token in Rust, and the same in the other server SDKs) at sign-in, and pass it with the context:
await flags.identify({ key: user.id }, { membershipTokens: tokensFromYourBackend })
Staying up to date
The client checks for changes every 5 seconds with a server key and every 60 seconds with a client key, and only downloads anything when something changed. In a browser it also checks as soon as the tab is shown again or the network comes back, at most every 5 seconds. How fast changes arrive has the whole picture.
When featflags can’t be reached, the client keeps serving the flags it has. In a browser it saves the last flags in localStorage and starts the next page load from them, even offline; flags.cached is true until the network answers. It never goes back to older flags than the ones it has. persist: false turns saving off.
flags.version is the version of the flags in use, and flags.subscribe(listener) calls you whenever evaluations may have changed (newer flags, a new context, or its shards loaded), returning a function that unsubscribes.
On a server
On Node, one client with a server key serves every request. Pass each request’s context to the evaluation instead of calling identify, and use the …Async methods (boolAsync, stringAsync, numberAsync, jsonAsync, boolDetailAsync, stringDetailAsync, numberDetailAsync, detailAsync):
const flags = new FlagClient({ key: process.env.FEATFLAGS_KEY!, cdnUrl: 'https://flags.featflags.io', eventsUrl: 'https://events.featflags.io' })
app.get('/checkout', async (req, res) => {
const context = { key: req.user.id, plan: req.user.plan }
if (await flags.boolAsync('new-checkout', false, context)) return renderNewCheckout(res)
renderCheckout(res)
})
The …Async methods only differ when a flag uses a list of more than 64 keys and the client doesn’t have that user’s shard yet: they fetch it and wait, up to membershipTimeout (500 ms) per evaluation, where the plain methods can’t wait and answer membership_pending with the value served without the list. Shards are kept for the 1,024 most recently used (membershipCache).
Edge runtimes
The same package runs in Cloudflare Workers and Vercel Edge Functions. Timers don’t outlive a request there, so create the client with timers: false and hand tick() to waitUntil after each response: it checks for changes once the poll interval has passed and sends counts once the flush interval has. Make the client in the first request, not at global scope, where Workers allow no network calls, timers or random values; it then lives as long as the isolate, and only an isolate’s first request waits for the flags.
// Cloudflare Workers
import { FlagClient } from '@featflags/js'
let flags: FlagClient | undefined
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
flags ??= new FlagClient({ key: env.FEATFLAGS_KEY, cdnUrl: 'https://flags.featflags.io', eventsUrl: 'https://events.featflags.io', timers: false })
await flags.waitForReady() // only the isolate's first request waits
const checkout = await flags.boolAsync('new-checkout', false, { key: userId })
ctx.waitUntil(flags.tick()) // after the response: check for changes and send counts when due
return Response.json({ checkout })
},
}
// Vercel Edge Functions (or waitUntil from @vercel/functions, as in Next.js)
import { FlagClient } from '@featflags/js'
export const config = { runtime: 'edge' }
let flags: FlagClient | undefined
export default async function handler(request: Request, context: { waitUntil(promise: Promise<unknown>): void }): Promise<Response> {
flags ??= new FlagClient({ key: process.env.FEATFLAGS_KEY!, cdnUrl: 'https://flags.featflags.io', eventsUrl: 'https://events.featflags.io', timers: false })
await flags.waitForReady()
const checkout = await flags.boolAsync('new-checkout', false, { key: userId })
context.waitUntil(flags.tick())
return Response.json({ checkout })
}
One client serves an isolate’s concurrent requests, so pass the context to each evaluation and use the …Async methods, as on any server. Flags stay fresh from the CDN rather than Workers KV, whose writes take up to a minute to reach every location and would hold a change back as long. Counts an isolate holds when it’s evicted are lost, at most one flush interval’s worth.
Experiments
When a flag is in a running experiment, the client reports which variant each user saw and the events you track(), under IDs computed with your environment’s experiment key, which featflags never holds. It needs the key, or the user’s seeds computed from it:
// Node, with the key: seeds for the browser, and track() for conversions on the server
const flags = new FlagClient({ key: 'sk_…', cdnUrl, eventsUrl, experimentKey: process.env.FEATFLAGS_EXPERIMENT_KEY })
res.json({ experimentSeeds: flags.experimentSeeds({ key: user.id }) })
flags.track('checkout.completed', order.total, { key: user.id })
// Browser, signed in: seeds only
flags.identify({ key: user.id }, { experimentSeeds })
flags.track('checkout.completed', 49.9)
Keep the key (xk_…) on your servers and hand each signed-in user’s browser its own seeds, as you do private segment tokens. A browser given the key itself ships it to everyone who loads the page, so only do that for anonymous visitors, and treat it as public.
track(eventKey, value?, context?) takes an event key matching [a-z0-9_.:-]{1,64} and an optional number, nothing else. It sends an event to every running experiment whose metrics count that key, and nothing when none does, so tracking freely costs nothing. A bad key or value is logged once and dropped, never thrown. Exposures and events go out with the counts: every minute, when the page hides, on close(), and straight away once 5,000 are waiting. After you rotate the experiment key, a client with the old key or old seeds sends nothing, and logs why, until it gets new ones.
Hooks
Hooks run around every evaluation and every identify, for logging, metrics or tracing:
const timing = {
name: 'timing',
beforeEvaluation: (series, data) => ({ ...data, start: performance.now() }),
afterEvaluation: (series, data, detail) => {
log(series.flagKey, detail.reason, performance.now() - (data.start as number))
return data
},
}
new FlagClient({ key, cdnUrl, hooks: [timing] })
beforeEvaluation runs in the order hooks were added and afterEvaluation in reverse; each hook’s after stage gets what its own before stage returned. beforeIdentify and afterIdentify do the same around identify. A hook that throws is logged and skipped, and the evaluation goes on. track runs no hooks. The SDK specification has the full contract.
OpenTelemetry
otelHook() adds a feature_flag.evaluation event to the active span, following OpenTelemetry’s semantic conventions for feature flags. It’s a separate entry point, so the core bundle doesn’t grow, and needs @opentelemetry/api:
import { otelHook } from '@featflags/js/otel'
new FlagClient({ key, cdnUrl, hooks: [otelHook()] })
Shutting down
await flags.close() stops polling, removes its page listeners, sends pending counts, exposures and events, and waits for storage writes. Call it before a short-lived script or job exits. await flags.flush() sends what’s pending without stopping. In a browser you don’t need either: the client sends what it has when the page hides.
Browser origins
An environment can list the origins its client key may be used from. On a page whose origin isn’t listed, the client serves only fallbacks (reason error, naming the origin), logs one error, and sends nothing. The list keeps your key from being reused on someone else’s site by accident; anyone can still fetch the flags directly, which is why browser flags are limited to the ones you mark client-visible.