In this article
November 4, 2026
November 4, 2026

Evaluate feature flags locally in Node with the WorkOS runtime client

The WorkOS Node SDK ships a feature flags runtime client that keeps flag configuration in memory, so a flag check on a hot path never waits on a request.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

A flag check that waits on a remote call puts a network dependency on a code path that can't afford one, and the alternative of carrying every flag in the user's access token runs into a size budget shared with your other claims. The WorkOS Node SDK's feature flags runtime client avoids both: it holds flag configuration in memory and evaluates locally, syncing with the Dashboard in the background. If your backend runs on Node, the docs point you here, especially once your flag set is large.

Two ways to read WorkOS feature flags. With the feature_flags claim, flags are written into the access token at sign-in or refresh, count toward token size, and are read in a request handler that needs a user session. With the Node runtime client, a background sync pulls flag configuration from WorkOS into an in-memory client every 30 seconds by default, and isEnabled evaluates locally on any code path.
Reading the claim ties a flag check to the session that carried it. The runtime client answers from local state and moves the network call off the request path into a background sync.

Why evaluate flags locally on Node?

There are two ways to read WorkOS feature flags in an application, and which one fits depends on your platform and on what the check is for.

The feature_flags claim in an AuthKit access token holds the slugs of the flags enabled for that user, based on your organization and user targeting. Reading it needs no extra request, because the flags arrive with the session the user already has. The claim travels inside the token, so it works on any platform whose AuthKit SDK gives you an authenticated session, and on Node it still works well for a small, stable set of flags.

Two things push you off it.

The first is freshness. A flag you change in the Dashboard reaches the user the next time they sign in or their session refreshes, rather than within a polling interval.

The second is size. Every flag in the claim takes up space in the access token, which is stored in the AuthKit session cookie, and that space is shared with your role, permission and entitlement claims under a 3,072-byte limit. A flag set that grows over the life of a product ends up competing with claims you can't drop.

Then there's the check the claim can't serve at all: a background job or a webhook handler has no session to read a claim from. The runtime client is built for that case, and it stays in sync independently of user authentication. It ships in the Node SDK only.

Which version of the Node SDK do you need?

Pin version 12 or later.

  
npm install @workos-inc/node@^12
  

The runtime client guide covers @workos-inc/node version 12.0.0 or later. Earlier versions ignore rules that serve false, so they can return true where 12.0.0 returns false. That behavior arrived in v12.0.0, released on October 23, 2026, listed in the release notes as the breaking change "Evaluate ordered targeting rules". The SDK requires Node 22.11 or later.

If you use @workos-inc/authkit-nextjs, check which Node SDK it pulls in. Version 4.5.0 supports only @workos-inc/node 9 and 10; version 4.6.0 and later also accept 12. Upgrade the Next.js package and make sure the Node SDK it resolves to is 12 or later before you rely on the runtime client through it.

How do you create the client?

Create a single, shared runtime client when your process starts, not one per request: the client handles synchronization and evaluation for the lifetime of the process. The constructor is workos.featureFlags.createRuntimeClient(). Attach an error listener before anything else (an unhandled error event throws), then wait for the client to become ready with a timeout you choose.

  
import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS(process.env.WORKOS_API_KEY);

const client = workos.featureFlags.createRuntimeClient();

client.on('error', (error) => {
  console.error('Feature flag sync failed:', error.message);
});

client
  .waitUntilReady({ timeoutMs: 5000 })
  .then(() => {
    const isEnabled = client.isEnabled('advanced-analytics');
    console.log('Feature enabled:', isEnabled);
  })
  .catch((error) => {
    console.error('Runtime client failed to initialize:', error);
  });
  

waitUntilReady() resolves once bootstrap data is provided or the first sync succeeds. Before then it rejects on a 401 or once timeoutMs passes. Bounding it at startup keeps a slow or failing first sync from hanging your boot sequence instead of failing it outright.

The runtime client belongs in trusted server-side environments only. Never expose your WorkOS API key in client-side code.

How do you evaluate a flag?

isEnabled returns true or false based on the flag's configuration and the targeting that matches the context you pass. Pass an organizationId, a userId, or both:

  
// Evaluate with organization targeting
const isEnabled = client.isEnabled('advanced-analytics', {
  organizationId: 'org_01EHQMYV6MBK39QC5PZXHY59C3',
});

// Evaluate with user targeting
const isEnabledForUser = client.isEnabled('advanced-analytics', {
  userId: 'user_01E4ZCR3C56J083X43JQXF3JK5',
});
  

Pass no context and an enabled flag returns its default value. If the flag doesn't exist, isEnabled returns false, and you can override that by passing a third argument as the default return value.

Targeting is set in the Dashboard, per environment, to None, Some or All for users and organizations. Flags exist across every environment in a project, so you can exercise one in a sandbox environment before turning it on in production. To target resources that belong to your own application, such as workspaces, read the custom targets guide.

What happens before the first sync completes?

Until the client has flag data, from bootstrap data or its first successful sync, isEnabled treats every flag as unknown and returns false, or the default value you pass as its third argument.

That's a decision your application has to make on purpose. A flag gating a new dashboard panel can safely read false for the short window before the first sync lands. A flag choosing between two write paths in a job runner can't.

Two ways to handle it:

  • Await waitUntilReady({ timeoutMs }) during startup and don't accept traffic until it resolves.
  • Pass bootstrapFlags so evaluations work immediately from saved configuration. Live data replaces the bootstrap data after the first successful sync.

Block on readiness when a wrong answer is expensive to undo, as in a job runner picking a write path. Bootstrap data plus a deliberate default is the better trade when the check only decides what a user sees.

If every flag comes back as its default value once you're running, the usual causes are a client that isn't ready yet, a flag key that doesn't match, or an evaluation context that matches no targeting.

How fresh are the flags?

The client starts its first sync as soon as it's created, and every evaluation after that is local. Freshness is therefore a function of the polling interval rather than of your traffic, and Dashboard changes show up on the next sync.

Option Type Default Description
pollingIntervalMs number 30000 How often to sync flag changes, in milliseconds. Minimum 5000
requestTimeoutMs number 10000 Timeout for each sync request, in milliseconds
bootstrapFlags FlagPollResponse undefined Pre-populated flag data for instant evaluation before the first sync
logger RuntimeClientLogger undefined Custom logger with debug, info, warn and error methods

Thirty seconds is the default and five seconds is the floor. Each sync request carries its own ten-second timeout. If you want to see flag changes land rather than infer them, listen for change, which fires when a flag's configuration changes after the first successful sync and gives you { key, previous, current }.

What should you watch for in production?

Three events carry everything you need to operate the client.

Event What it means
change A flag's configuration changed after the first successful sync
error A sync failed, including on a payload the SDK can't read. The client keeps its last flags and retries with exponential backoff, except after a 401, which also fires failed
failed A 401 Unauthorized. The client stops syncing automatically

The distinction that matters operationally: error is survivable and self-healing, since the client keeps serving the last flags it received. failed isn't. Your API key is invalid or unauthorized, and synchronization stays stopped until the client is recreated with valid credentials. Page on the second, alert on a sustained run of the first.

getFlag(key) helps here too. It returns the flag's configuration entry, which is what you want behind a debug endpoint when someone asks why a customer isn't seeing a feature.

Finally, stop the background sync when the process goes away. close() stops synchronization and cleans up resources.

  
// Gracefully shut down on server termination
process.on('SIGTERM', () => {
  client.close();
  process.exit(0);
});

process.on('SIGINT', () => {
  client.close();
  process.exit(0);
});
  

Claim or runtime client?

feature_flags claim Runtime client
Where evaluation happens In your code, from the token the session already carries Locally in your Node process, against in-memory configuration
Cost per check No extra request No remote request per evaluation
Freshness Next sign-in or session refresh Within the polling interval, 30 seconds by default
Token size Every flag uses access token space shared with role, permission and entitlement claims, under a 3,072-byte limit Flags stay out of the token, so flag count stops being a constraint
Platforms Any platform whose AuthKit SDK gives you a session Node only
Outside a user session Needs an authenticated session Syncs independently of user authentication

If you're setting up organization-level feature access for the first time, our walkthrough on enabling B2B SaaS features for specific customers covers creating flags and assigning them in the Dashboard.

The choice is per check, not per application. Keep the claim where a check lives inside an authenticated request and the flag set stays small and stable. Move to the runtime client the moment a check runs on a hot path, against a flag set that keeps growing, or inside a job, webhook or other long-lived process with no session to read from. On Node, that last case has only one answer.

Further reading