← Blog

Feature flags in Node.js: a practical guide

Feature flags in Node.js work the same way in a server process as they do in a browser: hash a user id and branch on the result. There is no framework adapter to install, because the bare SDK already runs on the server. Call assign() with a user id in an Express route, a queue worker, or a cron job, and you get a flag or an A/B test variant back with no database and no outbound request.

Most Node.js flag libraries assume a running service to call: a dashboard that stores the assignment, and a client that fetches it over the network on every request. That adds a round trip to your hot path and a second system whose state can drift from your code. dif skips both. The flag is a Markdown file in your repo, compiled into a typed client, and the variant is computed locally from the user id. This guide covers installing the SDK in a Node.js project, gating a route, running an experiment in a request handler, and validating flags in CI before they ship.

Key Takeaways

  • Add feature flags to Node.js with one package, @dif.sh/sdk, no framework adapter required for a plain server process.
  • Call assign("flag-id", { userId, attributes }) once per request to get a variant. There is no network call, because the result is a hash, not a lookup.
  • The SDK is about 5 kB gzipped with zero dependencies, so it adds no meaningful weight to a Node.js process.
  • dif validate runs in CI (GitHub Actions or any runner) and fails the build on a bad weight, a missing owner, or two experiments colliding on one surface.
  • Rollback is editing a number to 0, merging, and deploying. There is no second dashboard that can disagree with what is in the repo.

Install the SDK in a Node.js project

Install the CLI once, to scaffold the dif/ directory, and the SDK as a runtime dependency. The SDK is ESM-only, so set "type": "module":

npm install -g @dif.sh/cli
dif init --surface checkout
npm install @dif.sh/sdk express
npm pkg set type=module

dif init writes a dif/ directory and managed blocks into CLAUDE.md and AGENTS.md. No account, no API key, and nothing to log into. dif new drafts your first flag file:

dif new new-checkout

The draft lands in dif/experiments/active/new-checkout.md with status: draft. Edit it to a flag at 10% and set status: active:

---
id: new-checkout
status: active
owner: sam@acme.com
surface: checkout
hypothesis: >
  The rebuilt checkout lifts completed checkouts without moving refunds.
variants:
  - id: "off"
    weight: 90
  - id: "on"
    weight: 10
metrics:
  primary: completed_checkout
created: 2026-10-05
---

## Brief

Server-side rollout at 10%. Ramp to 25% once the guardrails hold for a week.

dif build compiles the active files into the client the SDK imports:

$ dif validate
✓ all checks passed
$ dif build
✓ validated 1 active experiment(s)
✓ typed client → dif/generated/client.ts
✓ audiences   → dif/generated/audiences.ts
✓ events      → dif/generated/events.ts
✓ context     → dif/context.json
dif/
  experiments/
    active/
      new-checkout.md
    concluded/
  surfaces/
    checkout.md
  audiences/
  generated/
    client.ts
    audiences.ts
    events.ts
  config.yaml
  context.json

dif/generated/ is gitignored, so a deploy has to run dif build before the server starts.

Gate a route with one function call

In a plain Node.js server, read the flag where you handle the request. With Express, that is the route handler. This is a complete server.js:

import "./dif/generated/client.ts";
import express from "express";
import { assign, dif } from "@dif.sh/sdk";

const app = express();
app.use(express.json());

// Stand-in for your auth middleware.
app.use((req, _res, next) => {
  const id = req.get("x-user-id");
  if (id) req.user = { id };
  next();
});

app.get("/checkout", (req, res) => {
  const userId = req.user?.id ?? req.ip;
  const flag = assign("new-checkout", { userId, attributes: {} });
  if (flag?.variant === "on") {
    return res.json(renderNewCheckout());
  }
  return res.json(renderLegacyCheckout());
});

const renderNewCheckout = () => ({ checkout: "new" });
const renderLegacyCheckout = () => ({ checkout: "legacy" });

app.listen(3000, () => console.log("listening on :3000"));

The first import registers the compiled flags with the SDK. assign() is the request-scoped call: it takes the user id and attributes explicitly, returns { variant, bucket, exposed }, and returns null for an id that is not registered, which is why the handler reads flag?.variant. The browser-style dif("id", branches) reads one user from a module-level dif.init(), which a server shared by every request should not use for assignment.

userId is whatever identifies the caller: a session user id, an API key, or, for anonymous traffic, the IP as a fallback. dif hashes it against the flag’s salt and returns the result in the same call, so nothing about this route is async for the purpose of the flag.

Run it. Node 22.18 or later loads the generated .ts file directly; on Node 20, start it with npx tsx server.js instead.

$ node server.js
listening on :3000

$ curl -s -H 'x-user-id: u_1' localhost:3000/checkout
{"checkout":"legacy"}
$ curl -s -H 'x-user-id: u_8131' localhost:3000/checkout
{"checkout":"legacy"}
$ curl -s -H 'x-user-id: u_15' localhost:3000/checkout
{"checkout":"new"}

$ dif qa --user u_15
trace u_15:
  • new-checkout → on (bucket 9088)

Buckets 0 to 8999 map to off and 9000 to 9999 to on, so u_15 at bucket 9088 is in the 10% arm. The CLI and the server agree because both run the same hash.

Run an A/B test in a request handler

A flag and an experiment are the same SDK call. The file has two named variants at 50/50 instead of off and on. It sits on its own surface, cart, because two active experiments on one surface must share an exclusion_group or have audiences that cannot overlap, or dif validate fails with E007. A surface is a Markdown file:

printf '# Surface: cart\n\nThe cart page and its order button.\n\n## Learnings\n' > dif/surfaces/cart.md
dif new checkout-cta-v2 --surface cart

Edit dif/experiments/active/checkout-cta-v2.md to this, then run dif build again:

---
id: checkout-cta-v2
status: active
owner: sam@acme.com
surface: cart
hypothesis: >
  "Get it today" on the order button lifts completed checkouts over "Place order".
variants:
  - id: control
    weight: 50
  - id: variant_a
    weight: 50
metrics:
  primary: completed_checkout
created: 2026-10-05
---

Instead of comparing a string, look the variant up in a map. Add this to server.js above app.listen:

app.get("/cart", (req, res) => {
  const userId = req.user?.id ?? req.ip;
  const cta = assign("checkout-cta-v2", { userId, attributes: {} });
  const labels = {
    control: () => "Place order",
    variant_a: () => "Get it today",
  };
  const ctaLabel = labels[cta?.variant ?? "control"];
  if (cta?.exposed) {
    console.log("exposure", "checkout-cta-v2", cta.variant, userId);
  }
  res.json({ ctaLabel: ctaLabel() });
});

assign() fires no exposure event. exposed is true when the user was bucketed into a real variant, so that is where you record the exposure in your own pipeline. The console.log stands in for it here.

Record the outcome where it actually happens, which in a Node.js backend is usually a later request, not the same one. dif.track() needs one dif.init() at boot to know where events go. In custom mode that is two functions you own:

dif.init({
  events: {
    mode: "custom",
    exposure: () => {},
    track: (event) => console.log("track", event.metric, event.user_id, event.value),
  },
});

app.post("/orders", (req, res) => {
  // after the order is created:
  dif.track("completed_checkout", { userId: req.user.id, value: req.body.total });
  res.sendStatus(201);
});

Restart the server and call both routes:

$ curl -s -H 'x-user-id: u_1' localhost:3000/cart
{"ctaLabel":"Get it today"}
$ curl -s -H 'x-user-id: u_2' localhost:3000/cart
{"ctaLabel":"Place order"}
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'x-user-id: u_1' \
    -H 'content-type: application/json' -d '{"total":49}' localhost:3000/orders
201

The server log shows the two exposures and the metric:

exposure checkout-cta-v2 variant_a u_1
exposure checkout-cta-v2 control u_2
track completed_checkout u_1 49

When variant_a wins, you ramp it by editing the weights in the flag’s frontmatter, merging, and deploying. The route code above does not change. The A/B testing for developers guide covers writing the hypothesis and reading the result.

No database, because assignment is a hash

A Node.js flag service usually needs somewhere to store who got which variant, so a second request for the same user returns the same answer. dif has no such store. The variant is the first four bytes of SHA-256(salt || userId), taken modulo 10,000, mapped to cumulative weights. Node’s built-in crypto module runs the same primitive; dif just fixes the inputs and the bucket count so the math is reproducible.

A worker process, a horizontally scaled API, and a background job all land on the same answer for the same user, with no shared cache and no sticky session. A flag check never blocks on I/O, either. For a team running Node.js behind a load balancer with no shared session store, that removes a whole class of “why did this user see two different variants” bugs.

The tradeoff: there is no dashboard toggle for an instant kill. Turning a flag off is editing its weight to 0 and redeploying. If your Node.js service deploys in two minutes, that is your worst-case kill time. For a flag protecting a payment path that needs a sub-second kill, pair it with an existing ops switch, not dif.

Validate feature flags in Node.js CI

dif validate runs anywhere Node.js runs, including a GitHub Actions job:

- name: Validate flags
  run: |
    npm install -g @dif.sh/cli
    dif validate

It checks that weights total 100, every owner is a real email, and every surface an experiment references actually exists. Any error exits 1, so a broken flag file fails the pull request in CI, the same way a failing test does, instead of surfacing in production. It also scans your .ts, .tsx, .js and .jsx source for dif("...") call sites and warns (W001) on an orphan: code calling a flag id that was renamed or removed in Markdown but not at the call site. That scan matches dif("id" literally, so it does not cover assign("id", ...) calls, and a warning exits 0.

Getting started

npm install -g @dif.sh/cli
dif init
npm install @dif.sh/sdk
dif new new-checkout

dif new pre-loads the draft with the last three learnings recorded on that surface, so a flag you write today starts from what the team already knows. Feature flags in git covers why the file lives in the repo at all, and the SDK docs cover every call the Node.js runtime supports. If your Node.js app is actually a React or SvelteKit frontend, the feature flags in React guide covers the provider and hook.

A Node.js feature flag comes down to one import and one function call. The flag lives in your repo as a file, compiles into a typed client, and resolves with no network call and no database, so the server that reads it stays simple and the user always gets a consistent answer.

FAQ

How do I add feature flags to a Node.js app? Install @dif.sh/sdk, run dif init to scaffold the dif/ directory, run dif build, and call assign("flag-id", { userId, attributes }) wherever you handle the request. No framework adapter is required for a plain Node.js server.

Is there a feature flag SDK built for Node.js specifically? The SDK is JavaScript and TypeScript and runs in Node.js without any adapter. React and Svelte get adapters because they need a provider; a Node.js backend just imports and calls assign().

Do feature flags in Node.js need a database? No. The variant is computed from a hash of the user id and the flag’s salt, so nothing is stored and a worker, a server, and a background job all compute the same answer independently.

Can I use dif with Express? Yes. Call assign() inside any route handler or middleware, the same as any other synchronous function. Express does not need to know anything about dif.

How do I test a flag in Node.js without affecting real users? Run dif qa --user u_8131 --attr device_type=mobile to trace exactly what that user gets and why, or mint a preview link that forces a variant in the browser and fires no exposure event, so a manual check never pollutes your numbers.