Skip to content

Node.js SDK Reference

TL;DR: Install @featureflip/node, call client.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.

View source on GitHub

  • Node.js 20.19.0+
Terminal window
npm install @featureflip/node

The NodeFeatureflipConfig interface accepts the following options:

OptionTypeDefaultDescription
sdkKeystringrequiredSDK key from the Featureflip dashboard
baseUrlstring"https://eval.featureflip.io"Base URL of the Evaluation API
streamingbooleantrueEnable SSE for real-time flag updates
pollIntervalnumber30000Polling interval in milliseconds (used when streaming is disabled)
flushIntervalnumber30000Interval in milliseconds between event flush batches
flushBatchSizenumber100Maximum number of events per flush batch
initTimeoutnumber10000Maximum 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
maxStreamRetriesnumber5Maximum SSE reconnection attempts before falling back to polling
snapshotStoreSnapshotStorenonePersists 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
maxSnapshotAgeSecondsnumber86400Oldest snapshot, in seconds, the client will start from. Only used with snapshotStore
wrappersstring[][]For libraries that wrap the SDK: name/version tokens sent after the SDK’s own identity
const client = FeatureflipClient.get({
sdkKey: 'sdk-dev-abc123',
streaming: true,
pollInterval: 60_000,
flushInterval: 15_000,
flushBatchSize: 50,
initTimeout: 5_000,
});

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 twice initTimeout, 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 different snapshotStore or set a different maxSnapshotAgeSeconds. 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:

docker-compose.yml
services:
api:
volumes:
- featureflip-snapshot:/var/lib/featureflip
volumes:
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.

client.startupInfo reports where the initial flag data came from:

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

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.

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.

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.

client.isInitialized: boolean

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

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.

const enabled = client.boolVariation('dark-mode', { user_id: 'u-123' }, false);

Signature:

boolVariation(key: string, context: EvaluationContext, defaultValue: boolean): boolean
const plan = client.stringVariation('pricing-tier', { user_id: 'u-123' }, 'free');

Signature:

stringVariation(key: string, context: EvaluationContext, defaultValue: string): string
const limit = client.numberVariation('rate-limit', { user_id: 'u-123' }, 100);

Signature:

numberVariation(key: string, context: EvaluationContext, defaultValue: number): number
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): T

Use variationDetail() when you need to understand why a particular value was returned.

const detail = client.variationDetail('dark-mode', { user_id: 'u-123' }, false);
console.log(detail.value); // true
console.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:

PropertyTypeDescription
valueTThe evaluated flag value
reasonEvaluationReasonWhy this value was returned
variationKeystring | undefinedThe key of the matched variation
ruleIdstring | undefinedThe ID of the matched targeting rule
ValueDescription
'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

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>): void

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

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): void

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): () => void

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

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 refcount 1.
  • Subsequent get() calls with the same SDK key return a new handle and increment the refcount. The config argument 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 on SIGTERM. 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.

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.

Forces all buffered events to be sent to the server immediately.

await client.flush();

Signature:

flush(): Promise<void>

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 values
client.boolVariation('dark-mode', {}, false); // true
client.stringVariation('pricing-tier', {}, 'free'); // 'enterprise'

Signature:

static forTesting(flags: Record<string, unknown>): FeatureflipClient

The test client is fully initialized immediately and returns the provided values regardless of the evaluation context.

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.

interface EvaluationDetail<T = unknown> {
value: T;
variationKey?: string;
reason: EvaluationReason;
ruleId?: string;
}
interface NodeFeatureflipConfig {
sdkKey: string;
baseUrl?: string;
streaming?: boolean;
pollInterval?: number;
flushInterval?: number;
flushBatchSize?: number;
initTimeout?: number;
maxStreamRetries?: number;
snapshotStore?: SnapshotStore;
maxSnapshotAgeSeconds?: number;
wrappers?: string[];
}

The Node SDK is a thin wrapper around @featureflip/js. Key differences:

@featureflip/node@featureflip/js
Platform setupAutomatic (Node.js)Manual (createNodePlatform() or createBrowserPlatform())
Base URLDefaults to https://eval.featureflip.ioRequired
Static factoryFeatureflipClient.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.