Node.js SDK Reference
TL;DR: Install
@featureflip/node, callclient.boolVariation('flag-key', { user_id: 'u-123' }, false), and you’re evaluating flags from any Node service.
The @featureflip/node package is the server-side wrapper around @featureflip/js, tuned specifically for Node.js processes. It pre-wires the Node fetch implementation, sets the SDK’s User-Agent (a header that browser fetch forbids), and configures connection pooling so a single long-lived client can handle a high request rate without leaking sockets. Node 20.19.0 or later is required — earlier versions ship a fetch implementation that doesn’t handle SSE reconnection cleanly.
Use this package on any Node.js workload: Express/Fastify/Koa/Hapi servers, AWS Lambda, Vercel serverless functions, BullMQ workers, NestJS microservices, or scheduled scripts. Feature flags are evaluated locally — there is no per-request network call to Featureflip — so adding flag checks to a hot endpoint costs roughly a Map.get. The streaming connection runs on a background timer and surfaces config updates within milliseconds, so a percentage rollout toggled in the dashboard takes effect across your fleet without a deploy.
The Node SDK is singleton-by-construction: there is no public constructor, only the static factory FeatureflipClient.get(config). Every call with the same SDK key returns a handle pointing at the same underlying shared client. You can call get() from anywhere in your process — inside a request handler, inside a DI container, inside a library — and the SDK guarantees you only ever get one set of background tasks and one SSE streaming connection per key. See the Lifetime section below for the full contract. For advanced targeting rules, pass an evaluation context with arbitrary attributes — anything serializable as JSON works.
Requirements
Section titled “Requirements”- Node.js 20.19.0+
Installation
Section titled “Installation”npm install @featureflip/nodeConfiguration
Section titled “Configuration”The NodeFeatureflipConfig interface accepts the following options:
| Option | Type | Default | Description |
|---|---|---|---|
sdkKey | string | required | SDK key from the Featureflip dashboard |
baseUrl | string | "https://eval.featureflip.io" | Base URL of the Evaluation API |
streaming | boolean | true | Enable SSE for real-time flag updates |
pollInterval | number | 30000 | Polling interval in milliseconds (used when streaming is disabled) |
flushInterval | number | 30000 | Interval in milliseconds between event flush batches |
flushBatchSize | number | 100 | Maximum number of events per flush batch |
initTimeout | number | 10000 | Maximum time in milliseconds to wait for the initial fetch. With snapshotStore, a failed fetch is followed by a store read capped at the same time, so the worst case is twice this value |
maxStreamRetries | number | 5 | Maximum SSE reconnection attempts before falling back to polling |
snapshotStore | SnapshotStore | none | Persists the last flag configuration so a process that starts while the Evaluation API is unreachable can still serve real values. See Starting during an outage |
maxSnapshotAgeSeconds | number | 86400 | Oldest snapshot, in seconds, the client will start from. Only used with snapshotStore |
wrappers | string[] | [] | For libraries that wrap the SDK: name/version tokens sent after the SDK’s own identity |
Example configuration
Section titled “Example configuration”const client = FeatureflipClient.get({ sdkKey: 'sdk-dev-abc123', streaming: true, pollInterval: 60_000, flushInterval: 15_000, flushBatchSize: 50, initTimeout: 5_000,});Starting during an outage
Section titled “Starting during an outage”By default a process that starts while the Evaluation API is unreachable serves your code defaults until the API comes back. Set snapshotStore to let it start from the last configuration it saved instead. The option is off unless you set it. For how this fits with the rest of the failure behavior, see Reliability.
import { FeatureflipClient, FileSnapshotStore } from '@featureflip/node';
const client = FeatureflipClient.get({ sdkKey: process.env.FEATUREFLIP_SDK_KEY!, snapshotStore: new FileSnapshotStore('/var/lib/featureflip/snapshot.json'), maxSnapshotAgeSeconds: 6 * 60 * 60,});How it behaves:
- The snapshot is read only when the initial fetch fails or times out. A successful fetch always wins, and the snapshot is never consulted afterward.
- After a start from a snapshot the client keeps retrying the live API and replaces the snapshot’s values as soon as it connects.
- The client writes the full configuration after each full update it applies (the first fetch, a poll, a stream resync). Once it is connected live, single-flag updates write it too.
- While the client stays connected and healthy, it refreshes the stored copy at most every 5 minutes even when nothing changed, so a quiet configuration does not age past
maxSnapshotAgeSeconds. Changes are written right away. - The store read is bounded by
initTimeout. If the fetch times out and then the store read hangs too,waitForInitialization()resolves after twiceinitTimeout, serving defaults. - The option is read on the first
get()for an SDK key. Later calls with the same key reuse the existing client, as described under Lifetime, and log a warning if they pass a differentsnapshotStoreor set a differentmaxSnapshotAgeSeconds. Leaving the age out never warns.
A snapshot contains your targeting rules, including any customer attribute values used in them. On POSIX systems FileSnapshotStore creates the file readable by its owner only (mode 0600).
Container disks are usually ephemeral, so a file written inside the image or container layer is gone after a redeploy. Point the path at a mounted volume:
services: api: volumes: - featureflip-snapshot:/var/lib/featureflipvolumes: featureflip-snapshot:On Kubernetes, mount a PersistentVolumeClaim, or an emptyDir if you only need the file to survive container restarts within a pod, at the same path.
FileSnapshotStore won’t create the parent directory, so it must already exist. Check the owner too. Docker creates a new named volume owned by root unless the image already has that directory, so a process running as a non-root user fails every write and logs a warning each time. Create the directory in your Dockerfile and hand it to that user, for example RUN mkdir -p /var/lib/featureflip && chown node /var/lib/featureflip.
startupInfo
Section titled “startupInfo”client.startupInfo reports where the initial flag data came from:
| Field | Values | Meaning |
|---|---|---|
source | 'live', 'snapshot', 'none' | live: the initial fetch succeeded, or the client has since connected after starting from snapshot or none. snapshot: the client started from the store. none: no data yet, so evaluations return your defaults |
reason | 'snapshot', 'snapshot-expired', 'snapshot-invalid' | Set when a snapshot was considered, and cleared once the client is live. snapshot: it was used. snapshot-expired: it was older than maxSnapshotAgeSeconds. snapshot-invalid: the stored text could not be used. That covers a garbage, empty or cut-off file, a snapshot written for a different SDK key or base URL, and a timestamp more than 60 seconds in the future. An empty store sets no reason |
snapshotAgeSeconds | number | Age of the snapshot that was used or rejected for age |
const { source, reason, snapshotAgeSeconds } = client.startupInfo;if (source !== 'live') { logger.warn({ source, reason, snapshotAgeSeconds }, 'flags did not load from the API');}Custom stores
Section titled “Custom stores”To share one snapshot across processes, implement SnapshotStore. It stores text: get() returns the string last stored, or undefined when there is none, and put(text) replaces it. This sketch uses Redis:
import type { SnapshotStore } from '@featureflip/node';import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const key = 'featureflip:snapshot:production';
const snapshotStore: SnapshotStore = { get: async () => (await redis.get(key)) ?? undefined, put: async (text) => { await redis.set(key, text); },};The store never needs to parse the text. The SDK wraps the configuration in its own envelope, with a timestamp and a hash of the SDK key and base URL, and checks all of it on read. Text written for a different environment is rejected as snapshot-invalid and never served, which is why one store instance can safely be shared by clients for several SDK keys. Still give each environment its own key or path. Two environments sharing one slot overwrite each other’s snapshot.
A store that throws or rejects is treated as having no snapshot, or as a failed write.
Initialization
Section titled “Initialization”FeatureflipClient.get()
Section titled “FeatureflipClient.get()”The primary entry point. Synchronously returns a handle; the shared core loads flag configuration in the background.
import { FeatureflipClient } from '@featureflip/node';
const client = FeatureflipClient.get({ sdkKey: 'sdk-dev-abc123',});
await client.waitForInitialization();Calling get() multiple times with the same SDK key returns different handles backed by the same shared client. See Lifetime.
FeatureflipClient.create()
Section titled “FeatureflipClient.create()”Convenience helper that calls get() and awaits initialization before returning.
const client = await FeatureflipClient.create({ sdkKey: 'sdk-dev-abc123',});waitForInitialization() does not reject when the initial fetch fails. It resolves after at most initTimeout milliseconds (twice that with snapshotStore, see Starting during an outage), and the client then serves your defaults, or the stored snapshot, while it keeps retrying in the background.
isInitialized
Section titled “isInitialized”client.isInitialized: booleanA read-only property that returns true once the client has successfully loaded flag data, and false after close() — a closed handle serves the caller’s default from every accessor.
Evaluating Flags
Section titled “Evaluating Flags”All evaluation methods accept a flag key, an evaluation context, and a default value. If the flag is not found or an error occurs, the default value is returned.
boolVariation()
Section titled “boolVariation()”const enabled = client.boolVariation('dark-mode', { user_id: 'u-123' }, false);Signature:
boolVariation(key: string, context: EvaluationContext, defaultValue: boolean): booleanstringVariation()
Section titled “stringVariation()”const plan = client.stringVariation('pricing-tier', { user_id: 'u-123' }, 'free');Signature:
stringVariation(key: string, context: EvaluationContext, defaultValue: string): stringnumberVariation()
Section titled “numberVariation()”const limit = client.numberVariation('rate-limit', { user_id: 'u-123' }, 100);Signature:
numberVariation(key: string, context: EvaluationContext, defaultValue: number): numberjsonVariation()
Section titled “jsonVariation()”interface BannerConfig { text: string; color: string;}
const banner = client.jsonVariation<BannerConfig>( 'banner-config', { user_id: 'u-123' }, { text: 'Welcome', color: '#000' },);Signature:
jsonVariation<T>(key: string, context: EvaluationContext, defaultValue: T): TEvaluation Details
Section titled “Evaluation Details”Use variationDetail() when you need to understand why a particular value was returned.
variationDetail()
Section titled “variationDetail()”const detail = client.variationDetail('dark-mode', { user_id: 'u-123' }, false);
console.log(detail.value); // trueconsole.log(detail.reason); // 'RuleMatch'console.log(detail.ruleId); // 'rule-abc'Signature:
variationDetail<T>(key: string, context: EvaluationContext, defaultValue: T): EvaluationDetail<T>Returns an EvaluationDetail<T> object:
| Property | Type | Description |
|---|---|---|
value | T | The evaluated flag value |
reason | EvaluationReason | Why this value was returned |
variationKey | string | undefined | The key of the matched variation |
ruleId | string | undefined | The ID of the matched targeting rule |
EvaluationReason
Section titled “EvaluationReason”| Value | Description |
|---|---|
'RuleMatch' | A targeting rule matched the context |
'Fallthrough' | No rules matched; the fallthrough variation was served |
'FlagDisabled' | The flag is disabled; the off variation was served |
'FlagNotFound' | The flag key does not exist |
'Error' | An error occurred during evaluation |
Event Tracking
Section titled “Event Tracking”track()
Section titled “track()”Records a custom analytics event.
client.track('purchase-completed', { user_id: 'u-123' }, { plan: 'pro', amount: 29.99,});Signature:
track(eventKey: string, context: EvaluationContext, metadata?: Record<string, unknown>): voidEvents are batched and flushed automatically based on flushInterval and flushBatchSize.
Featureflip counts each event name, per environment. The metadata you pass is transmitted with the event but is not stored, and event counts are not surfaced in the app or API.
identify()
Section titled “identify()”Records an Identify event for the given user. The event is counted per environment, and the attributes you pass are not stored.
This does not affect flag evaluation. Targeting rules and segments are matched against the context you pass to each variation call. identify() does not set a context for subsequent calls, and it does not persist user attributes for later targeting.
client.identify({ user_id: 'u-123', email: 'user@example.com', plan: 'pro' });Signature:
identify(context: EvaluationContext): voidReacting to Flag Changes
Section titled “Reacting to Flag Changes”Subscribes to flag-configuration updates. The listener receives the keys of the flags affected by each change, batched into one call however many flags it touched.
const unsubscribe = client.on('update', (keys) => { console.log('flags changed:', keys); cache.invalidate(keys);});A flag is reported when it is created, deleted, redefined, or when a segment its targeting rules reference changes. The listener does not fire for the initial flag load — use waitForInitialization() for that — and is never called with an empty array.
Returns an unsubscribe function. Listeners registered through a handle are also dropped automatically when that handle is closed, so a handle that goes away cannot leave a listener firing off the shared core.
Signature:
on(event: 'update', listener: (keys: string[]) => void): () => voidRemoves a listener previously registered with on(). Equivalent to calling the unsubscribe function on() returned.
client.off('update', listener);Listener errors are caught and logged — a throwing listener neither breaks flag delivery nor stops the remaining listeners.
Lifetime
Section titled “Lifetime”The Node SDK is singleton-by-construction: you cannot new a client directly, and every FeatureflipClient.get(config) call with the same SDK key returns a handle pointing at one shared background client. This is intentional — a Featureflip client maintains a long-lived SSE streaming connection, a periodic event flush loop, and an in-memory flag store. Opening more than one per SDK key per process wastes server resources and causes update delivery to fan out unpredictably.
The factory refcounts the shared core:
- The first
get()for a given SDK key constructs the shared core, kicks off initialization, and returns a handle with refcount1. - Subsequent
get()calls with the same SDK key return a new handle and increment the refcount. Theconfigargument on repeat calls is ignored; if it differs meaningfully from the first call’s config, a warning is logged. handle.close()decrements the refcount. When the refcount reaches zero, the shared core runs its real shutdown: closes the SSE connection, flushes any pending events, and removes itself from the factory cache.- Double-closing the same handle is a no-op — the handle tracks its own disposed state, so calling
close()twice does not double-decrement the refcount. - A different SDK key always constructs an independent shared core. Multi-tenant scenarios — one process serving flags for multiple environments — work naturally.
This makes the SDK safe under any lifetime pattern:
- Singleton at startup (the recommended pattern): call
get()once in your bootstrap code, inject the handle everywhere,close()it onSIGTERM. The refcount stays at 1 for the life of the process. - Per-request handler in Express/Fastify/NestJS: calling
get()on every request is harmless. Each request gets a fresh handle on the same underlying shared core — no new HTTP fetch, no new SSE connection, no new background timers. - Per-scope DI registration: scoped or transient registration is no longer catastrophic. Each scope resolves to a new handle sharing the same core.
- Serverless warm invocations: a warm Lambda or Vercel Function reuses the cached core across invocations automatically. Cold starts construct a fresh core per process, as expected.
Use FeatureflipClient.forTesting(flags) to obtain a standalone test client that is not registered in the factory cache — each call returns an independent instance with no background connections.
Cleanup
Section titled “Cleanup”close()
Section titled “close()”Decrements the refcount on the shared core. When the last handle for a given SDK key is closed, the shared core flushes pending events, closes the SSE connection, stops the polling timer, and removes itself from the factory cache.
await client.close();Signature:
close(): Promise<void>Call close() during graceful shutdown (e.g., in a SIGTERM handler) to ensure buffered analytics events are delivered before the process exits. Calling close() on a handle that has already been closed is a no-op — the handle tracks its own state and will not double-decrement the refcount.
flush()
Section titled “flush()”Forces all buffered events to be sent to the server immediately.
await client.flush();Signature:
flush(): Promise<void>Testing
Section titled “Testing”FeatureflipClient.forTesting()
Section titled “FeatureflipClient.forTesting()”Creates a test client with hardcoded flag values. No network calls are made and no background processes are started.
const client = FeatureflipClient.forTesting({ 'dark-mode': true, 'pricing-tier': 'enterprise', 'rate-limit': 500,});
// Evaluations return the test valuesclient.boolVariation('dark-mode', {}, false); // trueclient.stringVariation('pricing-tier', {}, 'free'); // 'enterprise'Signature:
static forTesting(flags: Record<string, unknown>): FeatureflipClientThe test client is fully initialized immediately and returns the provided values regardless of the evaluation context.
EvaluationContext
Section titled “EvaluationContext”type EvaluationContext = Record<string, unknown>;A plain object containing user attributes for flag evaluation. The user_id property is used for percentage rollouts and event tracking.
EvaluationDetail<T>
Section titled “EvaluationDetail<T>”interface EvaluationDetail<T = unknown> { value: T; variationKey?: string; reason: EvaluationReason; ruleId?: string;}NodeFeatureflipConfig
Section titled “NodeFeatureflipConfig”interface NodeFeatureflipConfig { sdkKey: string; baseUrl?: string; streaming?: boolean; pollInterval?: number; flushInterval?: number; flushBatchSize?: number; initTimeout?: number; maxStreamRetries?: number; snapshotStore?: SnapshotStore; maxSnapshotAgeSeconds?: number; wrappers?: string[];}Differences from @featureflip/js
Section titled “Differences from @featureflip/js”The Node SDK is a thin wrapper around @featureflip/js. Key differences:
@featureflip/node | @featureflip/js | |
|---|---|---|
| Platform setup | Automatic (Node.js) | Manual (createNodePlatform() or createBrowserPlatform()) |
| Base URL | Defaults to https://eval.featureflip.io | Required |
| Static factory | FeatureflipClient.create() | Not available |
Use @featureflip/node for Node.js applications. Use @featureflip/js directly if you need to support both Node.js and browser from the same codebase.
See also
Section titled “See also”- Node.js Quickstart — get started in under 5 minutes
- Using this SDK in Next.js — evaluating in Server Components and
getServerSideProps, plus the Edge-runtime caveat - Bootstrap client-side flags — hand the values this SDK evaluates to the browser SDK for the first paint
- Targeting & Segments — control which users see which variation
- Rollout Strategies — gradual percentage-based rollouts
- Evaluation API — REST API reference
- SDK Overview — compare server-side and client-side SDKs