Skip to content

nodejs

7 posts with the tag “nodejs”

OpenFeature flagd Node.js: Replace LaunchDarkly with a Self-Hosted Provider

LaunchDarkly’s pricing model has a well-documented inflection point. Feature flag evaluation is cheap per-call; access to the dashboard, the CLI, the audit log, and the enterprise tier isn’t. Teams that hit the threshold face a classic build-or-buy rethink — often for the first time after years of letting the LaunchDarkly SDK grow roots across their Node.js codebase.

The migration is a two-phase problem, and each phase is independently reversible.

  • Phase 1 (code migration): Use FlagLint to rewrite every direct LaunchDarkly SDK call to the OpenFeature API. Your flags keep evaluating through LaunchDarkly’s backend — nothing breaks.
  • Phase 2 (provider swap): Replace the LaunchDarkly OpenFeature provider with flagd and cut the vendor dependency entirely. One file changes.

This post walks both phases end-to-end using the OpenFeature flagd Node.js stack. If your application code already calls the OpenFeature API, jump straight to Phase 2.

What the OpenFeature flagd Node.js stack gives you

Section titled “What the OpenFeature flagd Node.js stack gives you”

OpenFeature is a CNCF-hosted specification for vendor-neutral flag evaluation. flagd is its reference provider: a lightweight evaluation server you run yourself. It reads flag definitions from a JSON file, a Kubernetes ConfigMap, or a remote endpoint; evaluates them with fractional rollout and targeting rule support; and exposes a gRPC endpoint your application connects to.

The OpenFeature flagd Node.js combination means:

  • No vendor account. flagd is a binary or Docker container you operate.
  • No pricing tier. Flag count, MAU, team seats — none of it applies.
  • Portable flag definitions. JSON files in your repo, versioned with your code.
  • Provider portability. If you later want Flipt or GrowthBook, swapping providers is a one-file change — the same change you’ll make when leaving LaunchDarkly.

Point FlagLint at your source directory:

Terminal window
npx flaglint@latest audit ./src

If you accidentally point it at a directory with no JavaScript or TypeScript files — a documentation folder, for example — FlagLint tells you immediately:

- Auditing ./src/content/docs/...
No matching files found. Check your .flaglintrc include patterns.

Point it at your application source. Running against a Node.js service with seven LaunchDarkly flag call sites across two files produces:

- Auditing ....
# FlagLint Audit Report
**Scanned at:** 2026-07-23T03:06:31.585Z
**Scan root:** /tmp/.../demo-app
**Files scanned:** 2
**Duration:** 45ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages |
|-------------|-----------|-------------|--------------|
| 7 | 1 | 6 | 7 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |
|--------------|--------------|------------|---------------|-------------------|---------------|
| 0 | 0 | 1 | 0 | 6 | 1 |
> **Staleness:** No staleness signals detected. Heuristics checked: keyword match (flag key
> contains old/deprecated/legacy/temp/tmp/test/demo), path pattern (test/spec/mock files,
> deprecated/old/legacy directories), and minFileCount threshold.
## Migration Readiness
Migration readiness: **86/100** · ready
[██████████████████████░░░] 86%
6 safely automatable · 1 require manual review
## Flag Debt Inventory
| Flag Key | Risk | Usages | Files | Call Types | Reasons |
|----------|------|--------|-------|------------|---------|
| `*` | 🔴 High | 1 | 1 | allFlagsState | bulk call |
| `new-dashboard` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
| `checkout-variant` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
| `payment-v2` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
| `rollout-percentage` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
| `checkout-v2` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
| `promo-banner` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
## Next Steps
- Run `flaglint migrate --dry-run` to preview safe OpenFeature rewrites
- Run `flaglint validate --no-direct-launchdarkly` to enforce OF boundary in CI
- Review HIGH risk flags manually before any automated migration
✓ Audit complete: 7 flags — 1 high risk, 6 medium risk (45ms, 2 files)

A readiness score of 86/100 means six call sites are safely automatable. The one high-risk item — allFlagsState — is a bulk inventory call with no single-flag codemod. Five LaunchDarkly SDK Patterns That Block Automatic Migration covers how to resolve it manually.

For a larger service, add --effort-estimate to get a migration hours projection before you commit to the work. See LaunchDarkly Flag Debt for how to read the output.

Before writing anything to disk, preview what changes:

Terminal window
npx flaglint@latest migrate --dry-run ./src
LaunchDarkly usages found: 7
Safely automatable: 6 · Manual review: 1
# FlagLint migrate --dry-run
These diffs use the placeholder `openFeatureClient` and require OpenFeature
provider/client setup before they can be applied.
No files are modified by dry-run output.
Reviewable diffs: 6
Diffs requiring provider setup: 6
Skipped usages: 1
## Diffs
diff --git a/featureFlags.ts b/featureFlags.ts
--- a/featureFlags.ts
+++ b/featureFlags.ts
@@ -7,1 +7,1 @@
- return client.variation('new-dashboard', user, false);
+ return openFeatureClient.getBooleanValue('new-dashboard', false, user);
@@ -12,1 +12,1 @@
- return client.variation('checkout-variant', user, 'control');
+ return openFeatureClient.getStringValue('checkout-variant', 'control', user);
@@ -17,1 +17,1 @@
- return client.variation('payment-v2', context, false);
+ return openFeatureClient.getBooleanValue('payment-v2', false, context);
@@ -22,1 +22,1 @@
- return client.variation('rollout-percentage', user, 0);
+ return openFeatureClient.getNumberValue('rollout-percentage', 0, user);
diff --git a/routes.ts b/routes.ts
--- a/routes.ts
+++ b/routes.ts
@@ -7,1 +7,1 @@
- const useNewFlow = await ldClient.variation('checkout-v2', user, false);
+ const useNewFlow = await openFeatureClient.getBooleanValue('checkout-v2', false, user);
@@ -8,1 +8,1 @@
- const showBanner = await ldClient.variation('promo-banner', user, false);
+ const showBanner = await openFeatureClient.getBooleanValue('promo-banner', false, user);
## Skipped Usages
- routes.ts:18:9 — `*` via `allFlagsState`: bulk inventory call has no single-flag codemod

Notice the argument order shift: variation(flag key, context, default) becomes getBooleanValue(flag key, default, context). FlagLint rewrites this at the AST level — not with find-and-replace — so it handles wrappers and aliased clients that grep misses. Getting the argument order wrong silently is the most common production bug in LaunchDarkly-to-OpenFeature migrations. See Why LaunchDarkly → OpenFeature Migrations Break in Production for the full explanation.

Phase 1: apply with the LaunchDarkly OpenFeature provider

Section titled “Phase 1: apply with the LaunchDarkly OpenFeature provider”

Apply the rewrites now, while keeping LaunchDarkly evaluating your flags. Flags continue to work exactly as before; you get to run your test suite before touching the backend at all.

Install the provider packages:

Terminal window
npm install @openfeature/server-sdk @launchdarkly/node-server-sdk @launchdarkly/openfeature-node-server

Add provider initialization at application startup:

bootstrap.ts
import { OpenFeature } from "@openfeature/server-sdk";
import { LaunchDarklyProvider } from "@launchdarkly/openfeature-node-server";
const provider = new LaunchDarklyProvider(process.env.LD_SDK_KEY!);
await OpenFeature.setProviderAndWait(provider);
export const openFeatureClient = OpenFeature.getClient();

Apply the rewrites:

Terminal window
npx flaglint@latest migrate ./src

Your call sites now use getBooleanValue, getStringValue, and getNumberValue from the OpenFeature server SDK. The LaunchDarkly SDK key is still required at runtime — the LaunchDarkly OpenFeature provider wraps it. But your application code no longer imports from @launchdarkly/node-server-sdk directly. You can replace the provider later without touching a single call site.

Once the OpenFeature migration is complete and your test suite passes, swapping the backend from LaunchDarkly to flagd takes two steps: write your flag definitions, then update the single bootstrap file.

Terminal window
# macOS / Linux via Homebrew
brew install open-feature/tap/flagd
flagd start --uri file:./flags.json
# Docker
docker run -p 8013:8013 ghcr.io/open-feature/flagd:latest start --uri file:./flags.json
{
"$schema": "https://flagd.dev/schema/v0/flags.json",
"flags": {
"new-dashboard": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "on"
},
"checkout-variant": {
"state": "ENABLED",
"variants": { "control": "control", "v2": "v2" },
"defaultVariant": "control"
},
"checkout-v2": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off"
},
"promo-banner": {
"state": "DISABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off"
}
}
}

Commit this file to your repository. Flag changes are now a JSON diff in a pull request — reviewable, auditable, and rolled back with a revert.

Update bootstrap.ts — the only file that changes

Section titled “Update bootstrap.ts — the only file that changes”
Terminal window
npm install @openfeature/server-sdk @openfeature/flagd-provider
// bootstrap.ts — only this file changes
import { OpenFeature } from "@openfeature/server-sdk";
import { FlagdProvider } from "@openfeature/flagd-provider";
await OpenFeature.setProviderAndWait(new FlagdProvider());
export const openFeatureClient = OpenFeature.getClient();

Delete LD_SDK_KEY from your environment. Every flag evaluation in your application now routes to flagd. Every call site you migrated in Phase 1 is unchanged.

This is the payoff of the OpenFeature flagd Node.js approach: provider-agnostic application code where the backend is genuinely swappable. If you later want percentage rollouts or targeting rules, flagd’s JSON schema supports both without changing application code. If you want to move to a hosted provider, the swap is again a one-file change.

Lock in the migration by blocking new direct LaunchDarkly SDK calls from landing:

Terminal window
npx flaglint@latest validate --no-direct-launchdarkly ./src

Add this step to your GitHub Actions workflow:

- name: Enforce OpenFeature boundary
run: npx flaglint@latest validate --no-direct-launchdarkly ./src

Full setup with SARIF annotation upload is covered in Enforcing Your LaunchDarkly to OpenFeature Migration in GitHub Actions.

The OpenFeature flagd Node.js stack resolves both categories of LaunchDarkly dependency: the SDK lock-in in your code (FlagLint handles Phase 1), and the vendor dependency in your infrastructure (flagd handles Phase 2). Phase 1 takes an afternoon for a typical Node.js service. Phase 2 is one file change and a bootstrap swap.

FlagLint Is Now Installable via Homebrew

Starting with v1.1.0, FlagLint is installable via Homebrew — no Node.js required.

Terminal window
brew tap flaglint/tap
brew install flaglint

That’s it. The full CLI is available immediately, including audit, scan, migrate, and validate.

Until now, the only install paths were npm install -g flaglint or npx flaglint@latest. Both work fine — but both require Node.js 20 or newer. That’s a reasonable assumption for application developers, but it’s a friction point in a few common situations.

DevOps and platform engineers often run tooling audits across repos without a Node.js environment set up. Installing Node just to run one CLI is the kind of thing that gets a tool skipped in favour of whatever’s already available.

Docker-based CI is the bigger one. A lot of teams run their CI in minimal images — no Node, no npm. Adding a Node install step purely to run npx flaglint adds 30-60 seconds to every pipeline run and pulls in a dependency that has nothing to do with the actual application. With Homebrew, a Linux CI job can install FlagLint in a single brew install call with no Node dependency.

Mac developers who use Homebrew for CLI tools now get brew upgrade flaglint like any other tool, without thinking about npm.

The tap lives at github.com/flaglint/homebrew-tap. The formula fetches the published npm tarball directly from the npm registry and wires up the CLI binary.

The formula updates automatically on every release — a GitHub Actions job in the main repo runs after each npm publish, computes the new tarball SHA256, and commits an updated formula to the tap. So brew upgrade flaglint will always pull the current release without any manual intervention.

Terminal window
brew tap flaglint/tap
brew install flaglint
flaglint --version

Or if you already have Node.js, npx flaglint@latest still works exactly as before. The Homebrew path is an addition, not a replacement.

Once installed, the quickstart walks through the full audit → preview → apply workflow.

How to Migrate from LaunchDarkly to OpenFeature in Node.js

Most LaunchDarkly to OpenFeature migrations start the same way: someone opens a PR with a find-and-replace across the codebase, the tests pass, the PR merges, and two days later a subset of users hits the wrong feature state in production.

The root cause is almost always the same one-line trap — and this guide shows you how to avoid it entirely, using a workflow that audits first, rewrites only what it can prove is safe, and enforces the boundary in CI when you’re done.

LaunchDarkly Flag Debt: Audit, Estimate, and Prioritize Your Migration

LaunchDarkly flag debt accumulates quietly. A team ships a feature behind a flag, verifies the rollout, and moves on. The flag stays. Six months later it is still being evaluated on every request — carrying the original business logic, an implicit dependency on the LaunchDarkly SDK, and a refactoring cost that compounds with every passing sprint.

At scale, the problem becomes a planning question as much as a technical one. Grepping for ldClient gives you a count, but it misses wrappers, misclassifies risk levels, and gives no indication of how long cleanup will actually take. Before you can schedule the work or make the case to your engineering manager, you need a measurement: how many direct LaunchDarkly SDK calls exist, which ones are safe to automate, and how many engineer-hours does this represent?

FlagLint produces that measurement from static source analysis alone. No LaunchDarkly API key required.

Run flaglint audit against your source directory:

Terminal window
npx flaglint audit ./src

Real output from the enterprise checkout service shipped with FlagLint (5 source files, run from examples/enterprise-checkout-service/):

- Auditing src/...
# FlagLint Audit Report
**Files scanned:** 5
**Duration:** 64ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages |
|-------------|-----------|-------------|--------------|
| 13 | 3 | 10 | 20 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |
|--------------|--------------|------------|---------------|-------------------|---------------|
| 8 | 1 | 1 | 0 | 10 | 10 |
## Migration Readiness
Migration readiness: **50/100** · moderate
[█████████████░░░░░░░░░░░░] 50%
10 safely automatable · 10 require manual review

The readiness score answers the foundational question before any migration begins: what fraction of your direct LaunchDarkly SDK call sites can a tool rewrite automatically? A score of 50 — moderate — means exactly half require a human to review before any automated step runs. A score below 50 is graded complex; 80 or above is ready, meaning the migration can proceed with minimal manual effort.

The stale signals column surfaces flag keys that carry staleness signal — flag keys containing keywords like old, deprecated, legacy, or tmp. Zero here means no flag key names carry obvious staleness signal at the source level. Git-based staleness detection, which checks last-evaluation date against git history, is outside the scope of a static scan.

Step 2: Measure the LaunchDarkly flag debt in engineer-hours

Section titled “Step 2: Measure the LaunchDarkly flag debt in engineer-hours”

A risk count tells you what you have. It does not tell you what it will cost to fix. Add --effort-estimate to get a directional planning number:

Terminal window
npx flaglint audit ./src --effort-estimate

Real output:

## Estimated Migration Effort
| | Low | High |
|---|---|---|
| Automatable calls (10 calls) | 2.5h | 3.8h |
| Manual review calls (10 calls) | 15h | 30h |
| Validation & testing | 5.3h | 10.1h |
| **Total** | **22.8h** | **43.9h** |
> Estimates are directional planning guides based on call-site complexity. Actual effort
> depends on test coverage, team familiarity, and provider setup. FlagLint does not access
> runtime data or LaunchDarkly billing.
Migration readiness: 50/100 · moderate
[█████████████░░░░░░░░░░░░] 50%
10 safely automatable · 10 require manual review
Estimated migration effort: 22.8h – 43.9h
Estimates are directional. See the report for assumptions.

The estimate breaks into three phases. Automation covers running flaglint migrate --apply, reviewing the generated diffs, and merging — roughly 0.25 engineer-hours per automatable call site. Manual review is where the range widens: each call site that requires human inspection is estimated at 1.5–3h, because the effort depends on what the surrounding code does with the evaluated value and how complex the flag key resolution is. Validation adds 30% of the combined automation and manual total for test runs, CI, and integration checks.

Supplying --hourly-rate converts the estimate to an engineering cost range:

Terminal window
npx flaglint audit ./src --effort-estimate --hourly-rate 150

This appends Estimated cost: $3,420 – $6,585 to the summary output. It is a planning heuristic calibrated to call-site complexity, not a billing projection.

The audit report includes a per-flag breakdown. This is where you translate the summary numbers into a concrete migration plan:

| Flag Key | Risk | Usages | Call Types | Reasons |
|-----------------------|----------------|--------|--------------------------------------|-----------------------------|
| `<dynamic key>` | 🔴 High | 8 | boolVariation, stringVariation, ... | dynamic key, wrapper usage |
| `checkout-experiment` | 🔴 High | 1 | boolVariationDetail | detail evaluation |
| `*` | 🔴 High | 1 | allFlagsState | bulk call |
| `checkout-v2` | 🟢 Automatable | 1 | boolVariation | safely automatable |
| `payment-provider` | 🟢 Automatable | 1 | stringVariation | safely automatable |
| `discount-config` | 🟡 Medium | 1 | jsonVariation | safely automatable, json variation |

Three call types drive the high-risk category in this service:

Dynamic flag key (8 usages across 3 files) — the flag key is a variable or template literal rather than a string literal. In this service, flags-wrapper.ts is the source: it accepts flagKey as a parameter and calls the LaunchDarkly SDK internally. FlagLint classifies it as a wrapper and marks every call through it as high risk because it cannot statically determine which flag is being evaluated, verify the call type, or confirm the return type. The resolution is to extract each dynamic key path to a named constant per call site so subsequent flaglint audit runs can classify them as automatable.

Detail evaluation (1 usage) — boolVariationDetail returns an evaluation reason object alongside the flag value. OpenFeature has a getBooleanDetails equivalent, but the reason vocabulary differs from the LaunchDarkly SDK (TARGETING_MATCH vs RULE_MATCH). Code that inspects reason.kind or reason.ruleId must be updated alongside the call site. FlagLint cannot safely generate that transformation.

Bulk call (1 usage) — allFlagsState has no OpenFeature provider equivalent. This call type requires an architecture decision before the migration can proceed: enumerate the specific flag keys needed explicitly, or retain the LaunchDarkly SDK client for the bootstrap path while migrating all other call sites to OpenFeature.

The call-site difference between a high-risk and an automatable entry is visible in source:

// High risk — dynamic flag key, cannot be rewritten automatically
const result = await ldClient.boolVariation(flagKey, ctx, false);
// Automatable — static flag key, safely rewritable
const enabled = await ldClient.boolVariation("checkout-v2", ctx, false);
// becomes:
const enabled = await openFeatureClient.getBooleanValue("checkout-v2", false, ctx);

The only structural difference in the automatable rewrite is argument order: the OpenFeature provider convention places the fallback value at position two and the evaluation context at position three. The flag key is preserved exactly. No flag evaluation logic at LaunchDarkly changes.

For teams that need to share the findings with engineering leads or allocate sprint capacity, --format html produces a self-contained file with no external dependencies:

Terminal window
npx flaglint audit ./src --effort-estimate --format html --output flag-debt.html

The file includes the summary card row, the effort estimate table, and the sortable flag debt inventory. It can be attached to a JIRA ticket, linked in a PR description, or opened locally. No LaunchDarkly credentials appear in the output — the report contains only what the static scan detected.

Step 5: Track progress toward zero flag debt

Section titled “Step 5: Track progress toward zero flag debt”

After migrating a batch of call sites, run flaglint validate to confirm the OpenFeature boundary holds:

Terminal window
npx flaglint validate ./src --no-direct-launchdarkly

Real output before migration begins:

✗ validate --no-direct-launchdarkly: 20 direct LaunchDarkly evaluation call(s) found.
analytics.ts:51:43 — variationDetail("(dynamic key)")
analytics.ts:76:23 — boolVariationDetail("checkout-experiment")
analytics.ts:104:22 — allFlagsState(bulk inventory)
checkout.ts:40:9 — boolVariation("checkout-v2")
checkout.ts:49:9 — stringVariation("payment-provider")
checkout.ts:58:9 — boolVariation("one-click-checkout")
checkout.ts:67:9 — stringVariation("checkout-currency")
...
These files must migrate to OpenFeature before this rule passes.
Run `flaglint migrate --dry-run` to review the migration plan.

Add this command to your CI pipeline. flaglint validate --no-direct-launchdarkly exits non-zero when any direct LaunchDarkly SDK call is detected, blocking regressions as the migration lands across multiple PRs. The validate gate is the mechanism that turns a migration plan into a contract.

As you resolve manual-review call sites — extracting dynamic flag keys to named constants, migrating detail evaluations by hand, replacing bulk calls with enumerated evaluations — re-run the audit to watch the readiness score climb. At 80 or above, flaglint migrate --apply can handle the remaining LaunchDarkly flag debt in a single automated pass, and the CI validate gate will confirm the boundary is clean.

Five LaunchDarkly SDK Patterns That Block Automatic Migration to OpenFeature

Run flaglint migrate ./src --dry-run and you will see two kinds of results: call sites with a generated diff and call sites marked skip — manual review required. The skipped calls are not bugs in the tool. They are patterns where a mechanical rewrite would change runtime behavior in ways the tool cannot prove are safe.

This article covers the five patterns that produce skips and what you need to do for each.

After the LaunchDarkly Outage: Adding a Vendor-Neutral Abstraction Without a Full Migration

A provider outage can expose how deeply application code depends on a single feature-flag SDK. OpenFeature creates a neutral application boundary without forcing teams to abandon LaunchDarkly.

This article walks through the local audit, migration preview, and CI enforcement path that lets teams add that boundary incrementally.

Why LaunchDarkly → OpenFeature Migrations Break in Production

LaunchDarkly and OpenFeature both evaluate flags with three arguments, but the fallback and context positions are reversed. A naive codemod can produce valid-looking code that silently changes runtime behavior.

This article shows the argument-order trap and why FlagLint uses AST analysis before rewriting any call site.