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

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, 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 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 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
{
  "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, a separate package from @amplitude/analytics-node. Keep both: one sends events, the other evaluates flags.

Terminal
npm install @amplitude/experiment-node-server

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

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

For EU projects, add serverZone: 'eu' to these options. For logging and the full option list, see the Experiment Node.js SDK 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
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.

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
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
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. 4 files.

4 files, 88 lines
// -- .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
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 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, 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 are built from those replays, so they show where people click on the pricing page in each variant.
  • 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

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

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.