---
title: "How to use local evaluation for low-latency feature flags in Node.js"
description: "Evaluate Amplitude feature flags inside your Node.js process with no network call per request, keep rules fresh with streaming, and track exposures."
canonical: "https://amplitude.com/en-us/build/nodejs-local-evaluation"
---

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

Evaluate Amplitude feature flags inside your Node.js process with no network call per request, keep rules fresh with streaming, and track exposures.

Source: https://amplitude.com/en-us/build/nodejs-local-evaluation

---

# 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](https://amplitude.com/feature-management) 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](/build/nodejs-feature-flags).

## 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](/build/nodejs-product-analytics), 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](/build/nodejs-feature-flags), 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](https://app.amplitude.com/signup) 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](https://amplitude.com/docs/feature-experiment/workflow/configure) 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](https://amplitude.com/docs/feature-experiment/advanced-techniques/create-a-local-evaluation-flag) 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](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js#local-evaluation), the same package the remote client uses. Skip the install if the feature flags page already added it.

Terminal

```sh
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

```sh
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

```javascript
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](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js#local-evaluation).

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

```javascript
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

```javascript
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](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js#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](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

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

```sh
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](/build/nodejs-product-analytics), and `routes/billing.js` stays as it is on that page or [the feature flags page](/build/nodejs-feature-flags). 4 files.

4 files, 101 lines

```javascript
// -- .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

```text
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](https://amplitude.com/amplitude-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](https://amplitude.com/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](https://amplitude.com/heatmaps) are built from those replays, so they show where people click on the product list in each variant.
- [Guides and surveys](https://amplitude.com/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

[Web frameworksAdd feature flags and experiments to a Node.js backendRemote evaluation, JSON payloads, and when the network call is worth it.](/build/nodejs-feature-flags)[Web frameworksAdd product analytics to a Node.js backendThe identity middleware and shutdown flush 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 device IDs.](/build/react-feature-flags)

Reference docs

[Experiment Node.js SDK: local evaluation →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js#local-evaluation)[Local evaluation →](https://amplitude.com/docs/feature-experiment/local-evaluation)[Create a local evaluation flag →](https://amplitude.com/docs/feature-experiment/advanced-techniques/create-a-local-evaluation-flag)[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-local-evaluation.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)
