OpenFeature Node.js Tutorial: Evaluate Flags, Swap Providers, and Audit Your Flag Debt
Every Node.js codebase that uses feature flags eventually hits the same problem: the evaluation calls are scattered across dozens of files, all coupled to one vendor SDK. Swap the backend and you touch every call site. Retire a vendor contract and you’re doing a codebase-wide search-and-replace that breaks argument order on half the calls you touch.
OpenFeature fixes this by decoupling the evaluation API from the backend provider. Your application code calls a stable, vendor-neutral client. The provider — LaunchDarkly, flagd, Unleash, or anything else — sits behind an interface you swap without touching call sites. This OpenFeature Node.js tutorial walks through the full setup: installing the SDK, wiring a provider, evaluating flags in real code, and then using FlagLint to audit any existing direct LaunchDarkly SDK calls and generate safe migration diffs.
What OpenFeature gives you
Section titled “What OpenFeature gives you”OpenFeature is a CNCF standard for feature flag evaluation. The Node.js server SDK (@openfeature/server-sdk)
gives you four typed evaluation methods that map to the four flag types you actually use:
client.getBooleanValue(flagKey, defaultValue, evaluationContext)client.getStringValue(flagKey, defaultValue, evaluationContext)client.getNumberValue(flagKey, defaultValue, evaluationContext)client.getObjectValue(flagKey, defaultValue, evaluationContext)The argument order is flagKey → defaultValue → context. This differs from the LaunchDarkly SDK where
context comes second and the default comes last — the order swap is the most common source of silent
bugs during a LaunchDarkly to OpenFeature migration. The OpenFeature standard puts the default before
the context, and all providers must match it.
Step 1: Install the packages
Section titled “Step 1: Install the packages”For a new Node.js project, install only the OpenFeature server SDK and whichever provider backs it:
# Core SDK (always required)npm install @openfeature/server-sdk
# LaunchDarkly as your backend provider (keep your existing LD account)npm install @launchdarkly/openfeature-node-server @launchdarkly/node-server-sdkThe LaunchDarkly OpenFeature provider wraps the LaunchDarkly Node.js SDK. Your LaunchDarkly account, rules, segments, and flag keys stay exactly as they are. Only the call-site API changes.
Step 2: Initialize the provider once at startup
Section titled “Step 2: Initialize the provider once at startup”Wire the provider at application bootstrap, before any flag evaluation happens:
import { OpenFeature } from "@openfeature/server-sdk";import { LaunchDarklyProvider } from "@launchdarkly/openfeature-node-server";
const ldProvider = new LaunchDarklyProvider(process.env.LD_SDK_KEY!);await OpenFeature.setProviderAndWait(ldProvider);
export const featureClient = OpenFeature.getClient();setProviderAndWait blocks until the LaunchDarkly streaming connection is established and the
local flag cache is populated — the same guarantee you get from ldClient.waitForInitialization()
in the raw LaunchDarkly SDK. Once this resolves, featureClient is ready to use anywhere in your
application.
Step 3: Evaluate flags in your application code
Section titled “Step 3: Evaluate flags in your application code”A checkout module using direct LaunchDarkly SDK calls looks like this:
// Before: coupled to LaunchDarkly SDKimport ldClient from "./ldClient";
export async function getCheckoutConfig(userId: string) { const user = { key: userId }; const newCheckoutEnabled = await ldClient.boolVariation("new-checkout-flow", user, false); const discountRate = await ldClient.numberVariation("discount-rate", user, 0); const checkoutTheme = await ldClient.stringVariation("checkout-theme", user, "default"); const featureConfig = await ldClient.jsonVariation("feature-config", user, {}); return { newCheckoutEnabled, discountRate, checkoutTheme, featureConfig };}After wiring OpenFeature, the same module becomes:
// After: vendor-neutral OpenFeature call sitesimport { featureClient } from "./openfeature";
export async function getCheckoutConfig(userId: string) { const ctx = { targetingKey: userId }; const newCheckoutEnabled = await featureClient.getBooleanValue("new-checkout-flow", false, ctx); const discountRate = await featureClient.getNumberValue("discount-rate", 0, ctx); const checkoutTheme = await featureClient.getStringValue("checkout-theme", "default", ctx); const featureConfig = await featureClient.getObjectValue("feature-config", {}, ctx); return { newCheckoutEnabled, discountRate, checkoutTheme, featureConfig };}The evaluation context (ctx) replaces the LaunchDarkly user object. The targetingKey field is
the OpenFeature standard for the primary identifier; the LaunchDarkly provider maps it to the
LaunchDarkly targeting key automatically. Any additional user attributes you pass — email, plan,
country — are forwarded to LaunchDarkly as custom attributes and used in targeting rules unchanged.
Step 4: Audit your existing LaunchDarkly SDK flag debt
Section titled “Step 4: Audit your existing LaunchDarkly SDK flag debt”If you are adding OpenFeature to a codebase that already has direct LaunchDarkly SDK calls, start with
an audit before touching any call site. FlagLint’s audit command scans your source with AST analysis
(not regex) and produces a complete flag debt inventory with risk classification and a readiness score.
npx flaglint@latest audit ./srcReal output from a Node.js checkout service with six direct LaunchDarkly SDK call sites:
- Auditing ./src...# FlagLint Audit Report
**Scanned at:** 2026-08-27T03:03:30.875Z**Scan root:** /tmp/.../example-svc/src**Files scanned:** 1**Duration:** 41ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages ||-------------|-----------|-------------|--------------|| 6 | 2 | 4 | 6 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review ||--------------|--------------|------------|---------------|-------------------|---------------|| 1 | 1 | 0 | 0 | 4 | 2 |
## Migration Readiness
Migration readiness: **67/100** · moderate
[█████████████████░░░░░░░░] 67%
4 safely automatable · 2 require manual review
## Flag Debt Inventory
| Flag Key | Risk | Usages | Files | Call Types | Reasons ||----------|------|--------|-------|------------|---------|| `<dynamic key>` | 🔴 High | 1 | 1 | boolVariation | dynamic key || `premium-pricing` | 🔴 High | 1 | 1 | boolVariationDetail | detail evaluation || `new-checkout-flow` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable || `discount-rate` | 🟢 Automatable | 1 | 1 | numberVariation | safely automatable || `checkout-theme` | 🟢 Automatable | 1 | 1 | stringVariation | safely automatable || `feature-config` | 🟡 Medium | 1 | 1 | jsonVariation | safely automatable, json variation |
✓ Audit complete: 6 flags — 2 high risk, 4 medium risk (41ms, 1 files)
Migration readiness: 67/100 · moderateThe readiness score (67/100) tells you what fraction of flag debt can be safely automated. The two high-risk call types flagged here — a dynamic flag key and a detail evaluation — are the two call types that require manual review before any rewrite; both are documented in the five patterns that block automatic migration.
Step 5: Preview the migration diffs
Section titled “Step 5: Preview the migration diffs”For the four automatable call sites, run flaglint migrate --dry-run to preview the exact rewrites:
npx flaglint@latest migrate ./src --dry-runReal output:
- Scanning ./src...LaunchDarkly usages found: 6Safely automatable: 4 · Manual review: 2
## Diffsdiff --git a/checkout.ts b/checkout.ts--- a/checkout.ts+++ b/checkout.ts@@ -8,1 +8,1 @@- const newCheckoutEnabled = await ldClient.boolVariation('new-checkout-flow', user, false);+ const newCheckoutEnabled = await openFeatureClient.getBooleanValue('new-checkout-flow', false, user);@@ -9,1 +9,1 @@- const discountRate = await ldClient.numberVariation('discount-rate', user, 0);+ const discountRate = await openFeatureClient.getNumberValue('discount-rate', 0, user);@@ -10,1 +10,1 @@- const checkoutTheme = await ldClient.stringVariation('checkout-theme', user, 'default');+ const checkoutTheme = await openFeatureClient.getStringValue('checkout-theme', 'default', user);@@ -11,1 +11,1 @@- const featureConfig = await ldClient.jsonVariation('feature-config', user, {});+ const featureConfig = await openFeatureClient.getObjectValue('feature-config', {}, user);
## Skipped Usages- checkout.ts:19:24 — `flagKey` via `boolVariation`: dynamic key requires manual review- checkout.ts:25:29 — `premium-pricing` via `boolVariationDetail`: detail methods skippedThe diffs show the argument-order swap explicitly: numberVariation('discount-rate', user, 0) becomes
getNumberValue('discount-rate', 0, user). The default value and context swap positions. FlagLint
applies this swap mechanically and correctly on every automatable call site; doing it by hand at scale
is where the argument-order bug most commonly appears.
To apply the rewrites:
npx flaglint@latest migrate ./srcStep 6: Enforce the boundary in CI
Section titled “Step 6: Enforce the boundary in CI”Once you have migrated a module, lock it with a CI check that fails on any new direct LaunchDarkly SDK call:
npx flaglint@latest validate --no-direct-launchdarkly ./srcOn an unmigrated codebase, this exits non-zero and lists every violation:
✗ validate --no-direct-launchdarkly: 6 direct LaunchDarkly evaluation call(s) found.
checkout.ts:8:35 — boolVariation("new-checkout-flow") checkout.ts:9:29 — numberVariation("discount-rate") checkout.ts:10:30 — stringVariation("checkout-theme") checkout.ts:11:30 — jsonVariation("feature-config") checkout.ts:19:24 — boolVariation("(dynamic key)") checkout.ts:25:29 — boolVariationDetail("premium-pricing")
These files must migrate to OpenFeature before this rule passes.Add this to your CI pipeline as a required check. Any PR that introduces a new direct LaunchDarkly SDK call will fail. For a step-by-step GitHub Actions setup, see Enforcing Your LaunchDarkly to OpenFeature Migration in GitHub Actions.
Swapping providers later
Section titled “Swapping providers later”The value of this setup is that once your call sites are on the OpenFeature standard, swapping the
backend is a one-line change in your initialization code. Remove LaunchDarklyProvider and substitute
any other OpenFeature-compatible provider — flagd, Unleash, Harness, or a local in-memory provider
for testing:
import { InMemoryProvider } from "@openfeature/server-sdk";
const flags = { "new-checkout-flow": { defaultVariant: "on", variants: { on: true, off: false } }, "discount-rate": { defaultVariant: "default", variants: { default: 0, sale: 0.15 } },};
await OpenFeature.setProviderAndWait(new InMemoryProvider(flags));Your application code does not change. The in-memory provider is especially useful for unit tests — you set the flag state before each test, run the code, assert on the outcome, no LaunchDarkly connection required.
Next steps
Section titled “Next steps”This OpenFeature Node.js tutorial covered the core setup: provider initialization, flag evaluation, AST-based audit of existing LaunchDarkly SDK flag debt, dry-run migration diffs, and CI enforcement.
If your codebase has significant flag debt, read the flag debt guide for a planning-level view of how to estimate hours and prioritize across multiple services. If some of your call sites show up as high-risk in the audit, the five patterns guide covers each blocker and how to resolve it before applying the automated rewrite.
FlagLint is free and open-source. No LaunchDarkly API key is required for any of the commands above — the audit and migrate commands work from static source analysis alone.
npx flaglint@latest --help