---
title: "How to add feature flags and experiments to a Node.js backend"
description: "Create a server-side flag with a JSON payload, read it in an Express route with tight timeouts, track exposures, and fall back safely when a fetch fails."
canonical: "https://amplitude.com/en-us/build/nodejs-feature-flags"
---

# How to add feature flags and experiments to a Node.js backend

Create a server-side flag with a JSON payload, read it in an Express route with tight timeouts, track exposures, and fall back safely when a fetch fails.

Source: https://amplitude.com/en-us/build/nodejs-feature-flags

---

# How to add feature flags and experiments to a Node.js backend

Create a server deployment and a flag with a JSON payload, read the variant in an Express route, track the exposure for the right user, and serve a safe default when the fetch fails. About 15 minutes, all copy-paste.

- Node 20.6+
- Express
- Experiment Node SDK 1.x
- 15 min

Chris Tam · Last updated 7 October 2026

[Feature flags](https://amplitude.com/feature-management) on a Node.js backend decide what your API returns, so a price, a limit, or a ranking can change per user without a redeploy. Run the flag as [an experiment](https://amplitude.com/amplitude-experiment) and you learn whether it moved upgrades.

In this tutorial, you'll create a server-side flag with a JSON payload, read it in an Express route, and track exposures. It builds on the [Node.js product analytics page](/build/nodejs-product-analytics).

## What you'll build

An Express backend whose `GET /billing/plans` route reads the `annual-discount` flag for the requesting user, returns a 20% annual discount to users in the `on` variant and the current 10% to everyone else, and sends an `[Experiment] Exposure` for users who got the variant. `Plan Upgraded` keeps firing from `POST /billing/upgrade` with the same user ID, so it can serve as the experiment's metric. By the end you'll see the assignment and exposure in that user's event stream in Amplitude.

## Prerequisites

- The Express app from [Add product analytics to a Node.js backend](/build/nodejs-product-analytics), with `lib/amplitude.js`, the `amplitudeIdentity` middleware, and `routes/billing.js` in place. This page reuses `req.amplitudeIds` from that middleware.
- Node.js 20.6 or later and `"type": "module"` in `package.json`, as on that page.
- Access to Experiment in the same Amplitude project your events go to, so you can create a deployment and a flag.

No Amplitude account yet? A free account is enough for this tutorial; nothing here needs a paid plan. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Create a server deployment and a flag

Experiment has its own keys. A deployment serves a group of flags to one platform or service, and each deployment has a deployment key. A server deployment key is secret and belongs only on a server; a client deployment key is public and belongs in browser code. Your project API key is a third, separate key that sends analytics events. The [deployment docs](https://amplitude.com/docs/feature-experiment/workflow/configure) cover each type.

Create the deployment first:

1. Go to Experiment > Deployments, then click **Create Deployment**.
2. Choose the same Amplitude project your analytics events go to. The deployment key has to live in the project you send events to, or exposures won't line up with users.
3. Name it after the service, for example `billing-api`.
4. Choose **Server-side** as the type, click **Create a Deployment**, and copy the key it generates.

Then create the flag:

1. Go to Experiment > Feature Flags and click **Create A Feature Flag**.
2. Choose the same project and name the flag `Annual discount`. Experiment generates the flag key `annual-discount` from the name; that key is what your code reads.
3. Leave the evaluation mode on **Remote**, set the bucketing unit to the user, and click **Create**.
4. Choose the `billing-api` deployment from the **Deployment** drop-down.
5. Add a variant named `on`. In its **Payload** field, paste the JSON below, then click **Apply**.
6. In the Assignment panel, set the rollout to 50% so you can see both branches. Save, then set the flag to **Active**.

Payload for the `on` variant:

Payload for the on variant

```json
{
  "discountPercent": 20
}
```

Your code reads `discountPercent`, so you can try 15% or 25% later from Amplitude without shipping a deploy. Don't name a variant `off`; Experiment reserves it for users who fall outside the rollout. Keep payloads to configuration: the docs advise against sensitive data, since payloads show up in network traffic and logs.

## 2. Install the SDK and add the deployment key

Feature flags on a server use [the Experiment Node.js SDK](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js), a separate package from `@amplitude/analytics-node`. Keep both: one sends events, the other evaluates flags.

Terminal

```sh
npm install @amplitude/experiment-node-server
```

Add the server deployment key next to the project API key from the product analytics page:

.env

```sh
AMPLITUDE_API_KEY=your-project-api-key
AMPLITUDE_DEPLOYMENT_KEY=your-server-deployment-key
```

Unlike the project API key, this one is a secret. Keep `.env` out of version control, and set the value in your host's secret settings for production. Never send it to the browser or put it in a client bundle.

## 3. Initialize the remote client once, with tight timeouts

Create one module that owns the Experiment client, the same way `lib/amplitude.js` owns the analytics SDK. Every route imports from it, so the client is created once per process.

lib/experiment.js

```javascript
import { Experiment, LogLevel } from '@amplitude/experiment-node-server';

const deploymentKey = process.env.AMPLITUDE_DEPLOYMENT_KEY;

if (!deploymentKey) {
  throw new Error('AMPLITUDE_DEPLOYMENT_KEY is not set');
}

export const experiment = Experiment.initializeRemote(deploymentKey, {
  // The defaults (10 second timeout, 8 retries) are too high for a request path.
  fetchTimeoutMillis: 500,
  fetchRetries: 1,
  fetchRetryBackoffMinMillis: 0,
  fetchRetryTimeoutMillis: 500,
  logLevel:
    process.env.NODE_ENV === 'production' ? LogLevel.Warn : LogLevel.Debug,
});
```

**Why.** Every remote fetch is a network call made while your user waits for the response. With the SDK defaults, a slow or unreachable evaluation server can hold a request for 10 seconds and then retry up to 8 times. The docs call those defaults too high for most servers and use the values above in their own example, which caps the worst case at about one second before your fallback runs. On a cache hit, a fetch averages about 36ms; on a miss, about 194ms, per the [performance docs](https://amplitude.com/docs/feature-experiment/under-the-hood/performance-and-caching).

For EU projects, add `serverZone: 'eu'` to these options. For logging and the full option list, see the [Experiment Node.js SDK configuration](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js#configuration).

## 4. Fetch the variant in the route that uses it

This module wraps the two calls every server-side flag needs: a fetch with the user's IDs and a fallback when it fails, and an exposure for the moment the variant changes what you serve.

lib/flags.js

```javascript
import { amplitude } from './amplitude.js';
import { experiment } from './experiment.js';

// Fetches one flag for one user. Returns null when there's nothing to
// evaluate or the fetch fails, so callers fall back to the default.
export async function getVariant(flagKey, ids, userProperties = {}) {
  // Evaluation needs a user ID or a device ID; without either the
  // request fails, so skip it and serve the default.
  if (!ids.user_id && !ids.device_id) return null;

  try {
    const variants = await experiment.fetchV2(
      { ...ids, user_properties: userProperties },
      { flagKeys: [flagKey] },
    );
    return variants[flagKey] ?? null;
  } catch (err) {
    // fetchV2 throws once its retries are spent. Never let a flag
    // outage turn into a 500.
    console.warn(`[flags] ${flagKey} fetch failed, serving default:`, err?.message);
    return null;
  }

// Records that this user saw this variant. Call it where the variant
// changes what you serve, and never for a fallback.
export function trackExposure(flagKey, variant, ids) {
  if (!variant?.value || variant.value === 'off') return;
  amplitude.track('$exposure', { flag_key: flagKey, variant: variant.value }, ids);
}
```

**Why.** A server-side remote fetch records an `[Experiment] Assignment` event for the user, but no exposure. On a client, the SDK tracks exposures when your code reads a variant; on a server, nothing knows when the response actually reaches a user, so the docs say to track the exposure yourself when the user encounters the variant. `$exposure` with `flag_key` and `variant` is the documented exposure event, and Amplitude sets the `[Experiment] annual-discount` user property from it. The guard skips users with no variant, because the docs warn against sending exposures for fallback values.

Keep `getVariant` and `trackExposure` in the route that serves the variant, not in middleware. Middleware would fetch and expose on every request, including ones that never see a price, which dilutes the experiment. `flagKeys` limits each request to the one flag that route uses.

Passing `ids` from `req.amplitudeIds` matters as much. Experiment resolves the user from the same `user_id` and `device_id` your analytics events use, so the `user-` prefixed ID and the browser's device ID header from the product analytics page are what keep the assignment, the exposure, and `Plan Upgraded` on one person.

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing flags and events before it writes code, so it reads `annual-discount` instead of inventing `annualDiscount`. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## 5. Serve the payload and keep the metric on the same user

This file replaces `routes/billing.js` from the product analytics page. It adds `GET /plans`, which reads the flag, and keeps `POST /upgrade`, which tracks the metric.

routes/billing.js

```javascript
import { Router } from 'express';
import { amplitude } from '../lib/amplitude.js';
import { getVariant, trackExposure } from '../lib/flags.js';

export const billing = Router();

const MONTHLY_PRICES = { pro: 20, team: 50 }; // USD
const DEFAULT_DISCOUNT_PERCENT = 10;

// Only the "on" variant carries a new discount. Anything missing or
// out of range keeps today's pricing.
function readDiscountPercent(variant) {
  const value = variant?.value === 'on' ? variant.payload?.discountPercent : undefined;
  return typeof value === 'number' && value >= 0 && value <= 50
    ? value
    : DEFAULT_DISCOUNT_PERCENT;
}

billing.get('/plans', async (req, res) => {
  // Send properties the flag targets on with the request itself.
  const userProperties = req.user ? { plan: req.user.plan } : {};
  const variant = await getVariant('annual-discount', req.amplitudeIds, userProperties);
  const discountPercent = readDiscountPercent(variant);
  trackExposure('annual-discount', variant, req.amplitudeIds);

  res.json({
    annualDiscountPercent: discountPercent,
    plans: Object.entries(MONTHLY_PRICES).map(([plan, monthly]) => ({
      plan,
      monthly,
      // Rounded to cents.
      annual: Math.round(monthly * 12 * (100 - discountPercent)) / 100,
    })),
  });

billing.post('/upgrade', async (req, res) => {
  const { plan } = req.body;

  // ... your billing logic

  amplitude.track(
    'Plan Upgraded',
    { plan, source: 'billing_page' },
    req.amplitudeIds,
  );

  res.json({ ok: true, plan });
});
```

`server.js` from the product analytics page doesn't change: it already mounts `amplitudeIdentity` before `/billing` and flushes the analytics queue on shutdown.

**Why.** `readDiscountPercent` follows the docs' advice to keep sensible defaults and validate payloads. If someone edits the payload in Amplitude and types `"20"` as a string, or 200, users get today's 10% instead of a broken price. Sending `plan` in `user_properties` closes a race the remote evaluation docs warn about: a property Amplitude only learned from an Identify call a moment ago may not be visible to targeting yet, and cached responses can lag up to an hour, while properties in the request apply to that evaluation.

The exposure and `Plan Upgraded` both go through the analytics SDK's in-memory queue, so the `SIGTERM` flush from the product analytics page covers them. In a serverless handler, `await amplitude.flush().promise` before returning, or the exposure is lost when the runtime freezes.

## Verify it worked

Run `npm start`. While you verify, raise the flag's rollout to 100% so your test user lands in `on`. Then request the plans with a test device ID:

Terminal

```sh
curl http://localhost:3000/billing/plans \
  -H 'x-amplitude-device-id: test-device-12345'
```

Check in this order:

1. **Response and process logs.** The response shows `"annualDiscountPercent": 20` and a `pro` annual price of `192`. With both SDKs at Debug outside production, the terminal prints debug lines for the variant fetch and for the `$exposure` track call. If you see `fetch failed, serving default` instead, go to Troubleshooting.
2. **Ingestion Debugger.** Send `POST /billing/upgrade` with the same header and a `{"plan":"pro"}` body, then in Amplitude click Data, select Sources, and open the Ingestion Debugger tab. The successful requests chart shows the uploads. It shows counts, not individual events.
3. **User lookup.** Go to Users > User Profiles and search for `test-device-12345`. Within ten seconds to a minute, the event stream shows `[Experiment] Assignment` with `[Experiment] annual-discount.variant` set to `on`, `[Experiment] Exposure` with `[Experiment] Flag Key` set to `annual-discount` and `[Experiment] Variant` set to `on`, and `Plan Upgraded` with `plan: pro`. Amplitude renames `$exposure` to `[Experiment] Exposure` on ingestion.

Set the rollout back to 50% when you're done. Assignments come from requests that miss Amplitude's 60-minute evaluation cache, so repeat requests from the same test device usually don't add another one.

## Troubleshooting

### Every request returns the 10% default

The flag isn't reaching this deployment, or the user is outside the rollout. Confirm the flag is Active, the `billing-api` deployment is selected on the flag, the deployment lives in the same project as your API key, and the rollout covers your test user.

### The log says `fetch failed, serving default`

The fetch threw after its retry. Check `AMPLITUDE_DEPLOYMENT_KEY` holds the server deployment key, and for EU projects that `serverZone: 'eu'` is set in `lib/experiment.js`. Network egress rules that block `api.lab.amplitude.com` produce the same message.

### `GET /billing/plans` is slow when Amplitude is unreachable

That's the timeout plus one retry, about one second, before the fallback runs. Lower `fetchTimeoutMillis` if your latency budget is tighter. If every millisecond counts, evaluate flags in-process with local evaluation instead.

### `[Experiment] Assignment` appears but no `[Experiment] Exposure`

The route doesn't call `trackExposure`, the user got no variant (outside the rollout), or the process exited before the analytics queue flushed. Call `trackExposure` right after you use the variant, and in serverless handlers await `amplitude.flush().promise`.

### A targeting rule on `plan` doesn't apply right after an upgrade

The rule reads a property the evaluation hasn't seen yet. Keep `plan` in the `userProperties` you pass to `getVariant`; responses for properties that aren't in the request can stay cached for up to an hour.

### One person shows different variants in the browser and the API

The browser and the server send different IDs. Use the same `user-${id}` string in `setUserId` and on the server, and keep the `x-amplitude-device-id` header on API calls so anonymous requests resolve to the same device.

## Full code reference

Every file this page adds or changes, in one block. `lib/amplitude.js`, `middleware/amplitude-identity.js`, `routes/auth.js`, and `server.js` stay as they are on [the Node.js product analytics page](/build/nodejs-product-analytics). 4 files.

4 files, 88 lines

```javascript
// -- .env --
AMPLITUDE_API_KEY=your-project-api-key
AMPLITUDE_DEPLOYMENT_KEY=your-server-deployment-key

// -- lib/experiment.js --
import { Experiment, LogLevel } from '@amplitude/experiment-node-server';

const deploymentKey = process.env.AMPLITUDE_DEPLOYMENT_KEY;

if (!deploymentKey) {
  throw new Error('AMPLITUDE_DEPLOYMENT_KEY is not set');
}

export const experiment = Experiment.initializeRemote(deploymentKey, {
  fetchTimeoutMillis: 500,
  fetchRetries: 1,
  fetchRetryBackoffMinMillis: 0,
  fetchRetryTimeoutMillis: 500,
  logLevel:
    process.env.NODE_ENV === 'production' ? LogLevel.Warn : LogLevel.Debug,
});

// -- lib/flags.js --
import { amplitude } from './amplitude.js';
import { experiment } from './experiment.js';

export async function getVariant(flagKey, ids, userProperties = {}) {
  if (!ids.user_id && !ids.device_id) return null;

  try {
    const variants = await experiment.fetchV2(
      { ...ids, user_properties: userProperties },
      { flagKeys: [flagKey] },
    );
    return variants[flagKey] ?? null;
  } catch (err) {
    console.warn(`[flags] ${flagKey} fetch failed, serving default:`, err?.message);
    return null;
  }

export function trackExposure(flagKey, variant, ids) {
  if (!variant?.value || variant.value === 'off') return;
  amplitude.track('$exposure', { flag_key: flagKey, variant: variant.value }, ids);
}

// -- routes/billing.js --
import { Router } from 'express';
import { amplitude } from '../lib/amplitude.js';
import { getVariant, trackExposure } from '../lib/flags.js';

export const billing = Router();

const MONTHLY_PRICES = { pro: 20, team: 50 };
const DEFAULT_DISCOUNT_PERCENT = 10;

function readDiscountPercent(variant) {
  const value = variant?.value === 'on' ? variant.payload?.discountPercent : undefined;
  return typeof value === 'number' && value >= 0 && value <= 50
    ? value
    : DEFAULT_DISCOUNT_PERCENT;
}

billing.get('/plans', async (req, res) => {
  const userProperties = req.user ? { plan: req.user.plan } : {};
  const variant = await getVariant('annual-discount', req.amplitudeIds, userProperties);
  const discountPercent = readDiscountPercent(variant);
  trackExposure('annual-discount', variant, req.amplitudeIds);

  res.json({
    annualDiscountPercent: discountPercent,
    plans: Object.entries(MONTHLY_PRICES).map(([plan, monthly]) => ({
      plan,
      monthly,
      annual: Math.round(monthly * 12 * (100 - discountPercent)) / 100,
    })),
  });

billing.post('/upgrade', async (req, res) => {
  const { plan } = req.body;
  amplitude.track(
    'Plan Upgraded',
    { plan, source: 'billing_page' },
    req.amplitudeIds,
  );
  res.json({ ok: true, plan });
});
```

## Hand it to your agent

Prefer to have Cursor or Claude Code do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt

```text
Add Amplitude feature flags to this Node.js Express backend by following
https://amplitude.com/build/nodejs-feature-flags exactly. It assumes the
setup from https://amplitude.com/build/nodejs-product-analytics is in place;
if req.amplitudeIds doesn't exist yet, do that page first. Install
@amplitude/experiment-node-server and create the client once in
lib/experiment.js with Experiment.initializeRemote, reading
AMPLITUDE_DEPLOYMENT_KEY (a server deployment key, never a client key or
the project API key) from the environment, with fetchTimeoutMillis 500,
fetchRetries 1, fetchRetryBackoffMinMillis 0 and fetchRetryTimeoutMillis
500. Fetch flags only in the route that uses the result, through a helper
that passes req.amplitudeIds plus targeted user properties, sets flagKeys
to the one flag, catches errors, and serves a validated default. After
using the variant, track a $exposure event (flag_key, variant) with the
existing analytics SDK and the same IDs, and skip it when there's no
variant. Never fetch flags in global middleware. Don't hardcode
keys, don't invent flag keys, and don't send the deployment key to the
browser. Report every file you changed and anything I need to add to .env.
```

## What this unlocks

- [Feature Experiment](https://amplitude.com/amplitude-experiment) can run the same pricing change as an A/B test with `control` and `treatment` variants and `Plan Upgraded` as the primary metric, using the exposures this route records.
- [Session replay](https://amplitude.com/session-replay), on your frontend, lets you filter replays to users with the `[Experiment] annual-discount` user property and watch how they read the pricing page.
- [Heatmaps](https://amplitude.com/heatmaps) are built from those replays, so they show where people click on the pricing page in each variant.
- [Guides and surveys](https://amplitude.com/guides-and-surveys) can ask users in the `on` variant whether the annual price made the decision easier.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksAdd product analytics to a Node.js backendThe identity middleware and \`Plan Upgraded\` event this page builds on.](/build/nodejs-product-analytics)[Web frameworksAdd feature flags and experiments to a React appRead flags in the browser with a client deployment and the same user IDs.](/build/react-feature-flags)[Web frameworksUse local evaluation for low-latency flagsEvaluate flags in-process with no network call per request.](/build/nodejs-local-evaluation)

Reference docs

[Experiment Node.js SDK →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js)[Configure your experiment (deployments) →](https://amplitude.com/docs/feature-experiment/workflow/configure)[Remote evaluation →](https://amplitude.com/docs/feature-experiment/remote-evaluation)[Experiment event tracking →](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking)[Performance and caching →](https://amplitude.com/docs/feature-experiment/under-the-hood/performance-and-caching)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

Amplitude is the leading [all-in-one analytics platform](https://amplitude.com/) for building better products. Product and engineering teams use Amplitude to connect [product analytics](https://amplitude.com/amplitude-analytics) and [web analytics](https://amplitude.com/web-analytics) with [session replay](https://amplitude.com/session-replay), [heatmaps](https://amplitude.com/heatmaps), [experimentation](https://amplitude.com/amplitude-experiment), [feature flags](https://amplitude.com/feature-management), and [guides and surveys](https://amplitude.com/guides-and-surveys) in one unified workflow, with [AI agents](https://amplitude.com/ai-agents) working across all of it. Learn how Amplitude is building [self-improving products](https://amplitude.com/self-improving-products) with [Wave](https://amplitude.com/wave), helping teams turn product data into PRs.

For agents

[View raw .md](/build/nodejs-feature-flags.md)[llms.txt](/llms.txt)

## Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
