How to use local evaluation for low-latency feature flags in Node.js

Create a local evaluation flag, load its rules into your Express server before it takes traffic, evaluate it in a route with no network call, and track exposures for the users who see it. About 15 minutes, all copy-paste.

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

Chris Tam · Last updated 8 October 2026

A remote flag fetch puts a network round trip in front of every request that reads it. Local evaluation downloads your flag rules once and evaluates them inside your Node.js process, in about 0.025ms per flag.

In this tutorial, you'll create a local flag, load it before your server starts, evaluate it in an Express route without an await, and track exposures. It builds on the Node.js feature flags page.

What you'll build

An Express backend whose GET /products route evaluates the product-list-sort flag in memory for each request, sorts by popularity for users in the popularity variant and by newest for everyone else, and records an [Experiment] Exposure for users who get the variant. Flag changes you save in Amplitude reach the running server without a restart. By the end you'll see the 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 server.js in place. This page reuses req.amplitudeIds from that middleware, including the device ID the browser sends in the x-amplitude-device-id header.
  • Node.js 20.6 or later and "type": "module" in package.json, as on that page.
  • A server deployment in Experiment. If you followed the Node.js feature flags page, reuse its deployment and AMPLITUDE_DEPLOYMENT_KEY. Otherwise, step 1 covers creating one.
  • A long-running Node.js server. The local client downloads flag rules on startup and keeps them fresh in the background, so it suits a process that stays up, not a function that cold-starts on each request.

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 local evaluation flag

Local evaluation needs a server deployment. If you don't have one, go to Experiment > Deployments, click Create Deployment, choose the project your analytics events go to, choose Server-side as the type, and copy the key it generates. The deployment docs cover each key type.

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 Product list sort. Experiment generates the flag key product-list-sort from the name; that key is what your code reads.
  3. Set Evaluation Mode to Local, set the bucketing unit to the user, and click Create.
  4. Choose your server deployment from the Deployment drop-down.
  5. Add a variant named popularity and click Apply.
  6. In the All Other Users section, set bucketed by to Device ID, then set the rollout to 50% so you can see both branches. Save, then set the flag to Active.

Why. Local evaluation runs in your process, so it can't look up the Amplitude ID that remote evaluation resolves on Amplitude's servers. The docs for moving a flag to local evaluation switch bucketing from Amplitude ID to Device ID for that reason. Bucketing by device ID also keeps a visitor in the same variant before and after they sign in, as long as the browser sends the same device ID header.

Don't name a variant off; Experiment reserves it for users who fall outside the rollout. To move an existing remote flag instead of creating a new one, deactivate it first, because changing the evaluation mode changes bucketing.

2. Install the SDK and add the keys

Local evaluation uses the Experiment Node.js SDK, the same package the remote client uses. Skip the install if the feature flags page already added it.

Terminal
npm install @amplitude/experiment-node-server

The local client reads two keys: the server deployment key, to download flag rules, and your project API key, to send exposure events.

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

The deployment key is a secret. Keep .env out of version control, set both values in your host's secret settings for production, and never send the deployment key to the browser.

3. Initialize the local client once

Create one module that owns the local client and wraps evaluation. Every route imports from it, so the process holds one copy of the flag rules.

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

const deploymentKey = process.env.AMPLITUDE_DEPLOYMENT_KEY;
const apiKey = process.env.AMPLITUDE_API_KEY;

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

export const localFlags = Experiment.initializeLocal(deploymentKey, {
  // Amplitude pushes flag changes to the process, generally in under a second.
  streamUpdates: true,
  // If the stream fails, the SDK falls back to polling at this interval.
  flagConfigPollingIntervalMillis: 30000,
  // The project API key (not the deployment key) for exposure events.
  exposureConfig: { apiKey },
  logLevel:
    process.env.NODE_ENV === 'production' ? LogLevel.Warn : LogLevel.Debug,
});

// Evaluates one flag for one request in memory. Returns null when there's
// nothing to evaluate or evaluation fails, so callers serve the default.
export function evaluateFlag(flagKey, ids, userProperties = {}) {
  // The flag buckets by device ID; without one, serve the default.
  if (!ids.device_id) return null;

  try {
    const variants = localFlags.evaluateV2(
      { ...ids, user_properties: userProperties },
      [flagKey],
      { tracksExposure: true },
    );
    return variants[flagKey] ?? null;
  } catch (err) {
    console.warn(`[flags] ${flagKey} evaluation failed, serving default:`, err?.message);
    return null;
  }
}

Why. Without streamUpdates, the client polls for new rules every 30 seconds by default, so a rollback you save in Amplitude can take that long to reach the server. Streaming gets it there generally in under a second, and the SDK falls back to polling on its own if the stream fails. exposureConfig turns on exposure tracking; server-side SDKs don't track exposure automatically. The SDK sends exposures through its own internal analytics client, configured from exposureConfig, separate from the one in lib/amplitude.js.

For EU projects, add serverZone: 'eu' to these options. For the full option list, see the local evaluation section of the SDK reference.

4. Start the client before the server takes traffic

start() downloads the local flag rules and starts the background updates. Await it before app.listen, so the first request already has rules to evaluate. This file replaces server.js from the product analytics page and mounts the new products route.

server.js
import express from 'express';
import { amplitude } from './lib/amplitude.js';
import { localFlags } from './lib/local-flags.js';
import { amplitudeIdentity } from './middleware/amplitude-identity.js';
import { auth } from './routes/auth.js';
import { billing } from './routes/billing.js';
import { products } from './routes/products.js';

const app = express();
app.use(express.json());
// Mount your session or auth middleware here, before amplitudeIdentity,
// so req.user is populated.
app.use(amplitudeIdentity);
app.use('/auth', auth);
app.use('/billing', billing);
app.use('/products', products);

try {
  // Load flag rules before taking traffic.
  await localFlags.start();
} catch (err) {
  // Keep serving if Amplitude is unreachable at boot; routes fall back
  // to their defaults until rules load.
  console.warn('[flags] could not load flag configs, serving defaults:', err?.message);
}

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

async function shutdown(signal) {
  console.log(`${signal} received, flushing Amplitude`);
  server.close();
  await amplitude.flush().promise;
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Why. evaluateV2 only reads rules already in memory. Call it before start() resolves and it has nothing to evaluate, so every early request gets the default. The top-level await works because the project uses ES modules. The try keeps an Amplitude outage at boot from taking your API down with it.

If the feature flags page's routes/billing.js is in place, it keeps working as before: the remote client in lib/experiment.js and the local client here can run side by side, one per evaluation mode.

5. Evaluate the flag in the route that uses it

The route evaluates the flag synchronously, so there's no await and no network call on the request path.

routes/products.js
import { Router } from 'express';
import { evaluateFlag } from '../lib/local-flags.js';

export const products = Router();

// Stand-in for your catalog query.
const PRODUCTS = [
  { id: 'prod-1001', name: 'Starter kit', createdAt: '2026-09-28', unitsSold: 120 },
  { id: 'prod-1002', name: 'Team bundle', createdAt: '2026-08-14', unitsSold: 940 },
  { id: 'prod-1003', name: 'Pro add-on', createdAt: '2026-10-02', unitsSold: 310 },
];

const SORTS = {
  newest: (a, b) => b.createdAt.localeCompare(a.createdAt),
  popularity: (a, b) => b.unitsSold - a.unitsSold,
};

products.get('/', (req, res) => {
  // Local evaluation only sees the properties you pass in.
  const userProperties = req.user ? { plan: req.user.plan } : {};
  const variant = evaluateFlag('product-list-sort', req.amplitudeIds, userProperties);
  const sort = variant?.value === 'popularity' ? 'popularity' : 'newest';

  res.json({ sort, items: [...PRODUCTS].sort(SORTS[sort]) });
});

Why. Remote evaluation looks up the user properties Amplitude already knows about a user. Local evaluation can't, because it runs outside Amplitude, so a targeting rule on plan only matches when you pass plan in user_properties. Pass every property your rules read. Cohort targeting works too, but only on user IDs, and it needs a cohortSyncConfig with your project API key and secret key; see local evaluation cohort targeting.

Keep the evaluation in the route that changes what you serve, and pass [flagKey] so the SDK evaluates only that flag. With tracksExposure: true, an evaluation is an exposure, so calling evaluateFlag in global middleware would record exposures for requests that never show a product list.

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 product-list-sort instead of inventing productListSort. Set up the MCP server.

Verify it worked

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

Terminal
curl http://localhost:3000/products \
  -H 'x-amplitude-device-id: test-device-12345'

Check in this order:

  1. Response and process logs. The response shows "sort": "popularity" with Team bundle first. With logLevel at Debug outside production, the terminal prints the SDK's debug output at startup. If you see could not load flag configs instead, go to Troubleshooting.
  2. Live update. Set the rollout back to 0% and save, wait a couple of seconds, then run the same curl without restarting the server. The response flips to "sort": "newest", which confirms streaming is delivering changes.
  3. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart shows the exposure uploads. It shows counts, not individual events.
  4. User lookup. Go to Users > User Profiles and search for test-device-12345. The event stream shows [Experiment] Exposure with [Experiment] Flag Key set to product-list-sort and [Experiment] Variant set to popularity, and the profile gains the [Experiment] product-list-sort user property.

Set the rollout back to 50% when you're done.

Troubleshooting

Every request returns "sort": "newest"

The flag isn't reaching this client, or the request has no device ID. Confirm the flag's evaluation mode is Local, it's Active, your server deployment is selected on it, and the rollout covers your test device. Then confirm the request carries the x-amplitude-device-id header.

The log says could not load flag configs at startup

start() failed to download the rules. Check AMPLITUDE_DEPLOYMENT_KEY holds a server deployment key, not a client key or the project API key, and for EU projects that serverZone: 'eu' is set. Network egress rules that block api.lab.amplitude.com cause the same failure.

A change in Amplitude takes up to 30 seconds to apply

Streaming failed and the client fell back to polling. Check that egress to stream.lab.amplitude.com is allowed, and that streamUpdates: true is set in lib/local-flags.js.

A targeting rule on a user property never matches

Local evaluation doesn't fetch user properties from Amplitude. Pass the property the rule reads in userProperties, the way the route passes plan.

No [Experiment] Exposure in the event stream

exposureConfig is missing its apiKey, the call doesn't pass { tracksExposure: true }, or the device fell outside the rollout. The exposure goes through the SDK's own analytics client, so it can arrive a little after your other events.

The same visitor sees a different sort after signing in

The browser sent a different device ID, or none, on the signed-in request. Keep the x-amplitude-device-id header on every API call so bucketing stays on one device.

Full code reference

Every file this page adds or changes, in one block. lib/amplitude.js, middleware/amplitude-identity.js, and routes/auth.js stay as they are on the Node.js product analytics page, and routes/billing.js stays as it is on that page or the feature flags page. 4 files.

4 files, 101 lines
// -- .env --
AMPLITUDE_API_KEY=your-project-api-key
AMPLITUDE_DEPLOYMENT_KEY=your-server-deployment-key

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

const deploymentKey = process.env.AMPLITUDE_DEPLOYMENT_KEY;
const apiKey = process.env.AMPLITUDE_API_KEY;

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

export const localFlags = Experiment.initializeLocal(deploymentKey, {
  streamUpdates: true,
  flagConfigPollingIntervalMillis: 30000,
  exposureConfig: { apiKey },
  logLevel:
    process.env.NODE_ENV === 'production' ? LogLevel.Warn : LogLevel.Debug,
});

export function evaluateFlag(flagKey, ids, userProperties = {}) {
  if (!ids.device_id) return null;

  try {
    const variants = localFlags.evaluateV2(
      { ...ids, user_properties: userProperties },
      [flagKey],
      { tracksExposure: true },
    );
    return variants[flagKey] ?? null;
  } catch (err) {
    console.warn(`[flags] ${flagKey} evaluation failed, serving default:`, err?.message);
    return null;
  }
}

// -- routes/products.js --
import { Router } from 'express';
import { evaluateFlag } from '../lib/local-flags.js';

export const products = Router();

const PRODUCTS = [
  { id: 'prod-1001', name: 'Starter kit', createdAt: '2026-09-28', unitsSold: 120 },
  { id: 'prod-1002', name: 'Team bundle', createdAt: '2026-08-14', unitsSold: 940 },
  { id: 'prod-1003', name: 'Pro add-on', createdAt: '2026-10-02', unitsSold: 310 },
];

const SORTS = {
  newest: (a, b) => b.createdAt.localeCompare(a.createdAt),
  popularity: (a, b) => b.unitsSold - a.unitsSold,
};

products.get('/', (req, res) => {
  const userProperties = req.user ? { plan: req.user.plan } : {};
  const variant = evaluateFlag('product-list-sort', req.amplitudeIds, userProperties);
  const sort = variant?.value === 'popularity' ? 'popularity' : 'newest';
  res.json({ sort, items: [...PRODUCTS].sort(SORTS[sort]) });
});

// -- server.js --
import express from 'express';
import { amplitude } from './lib/amplitude.js';
import { localFlags } from './lib/local-flags.js';
import { amplitudeIdentity } from './middleware/amplitude-identity.js';
import { auth } from './routes/auth.js';
import { billing } from './routes/billing.js';
import { products } from './routes/products.js';

const app = express();
app.use(express.json());
app.use(amplitudeIdentity);
app.use('/auth', auth);
app.use('/billing', billing);
app.use('/products', products);

try {
  await localFlags.start();
} catch (err) {
  console.warn('[flags] could not load flag configs, serving defaults:', err?.message);
}

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

async function shutdown(signal) {
  console.log(`${signal} received, flushing Amplitude`);
  server.close();
  await amplitude.flush().promise;
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

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 local evaluation feature flags to this Node.js Express
backend by following https://amplitude.com/build/nodejs-local-evaluation
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. Use
@amplitude/experiment-node-server and create one client in
lib/local-flags.js with Experiment.initializeLocal, reading
AMPLITUDE_DEPLOYMENT_KEY (a server deployment key) and AMPLITUDE_API_KEY
(the project API key, used only in exposureConfig) from the environment,
with streamUpdates true and flagConfigPollingIntervalMillis 30000. In
server.js, await localFlags.start() inside a try/catch before
app.listen. Evaluate flags only in the route that uses the result, with
evaluateV2(user, [flagKey], { tracksExposure: true }), passing
req.amplitudeIds plus every user property the flag's targeting reads,
and serve a default when there's no device ID, no variant, or an
error. Never evaluate 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 product-list-sort as an A/B test, analyzed on the [Experiment] Exposure events this route records, while evaluation stays in-process.
  • Session replay, on your frontend, lets you filter replays to users with the [Experiment] product-list-sort user property and watch how they browse each sort order.
  • Heatmaps are built from those replays, so they show where people click on the product list in each variant.
  • Guides and surveys can ask users in the popularity variant whether they found what they came for.

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.