<!-- llms.txt: https://workos.com/llms.txt -->
<!-- feedback.md: https://workos.com/docs/feedback.md -->

# Runtime client

## Overview

The WorkOS runtime client evaluates feature flags in server-side applications. The client maintains internal flag state, allowing it to serve feature flags without remote requests. It polls for changes in the background, so flag configurations stay in sync with the dashboard automatically.

This guide covers:

- [`@workos-inc/node`](https://workos.com/docs/sdks/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`.
- The [`workos`](https://workos.com/docs/sdks/python) Python package version `10.8.0` or later.

Choose your language in [Install the SDK](#1-install-the-sdk), or from the language menu on any code block. The code examples and SDK-specific details on this page follow your choice. Sections that apply to one SDK only are grouped near the end of the page.

Until the client has flag data, from bootstrap data or its first successful poll, it treats every flag as unknown and returns `false`, or the default value you pass.

Create a single, shared runtime client when your application starts. Do not create a new client per request — the client handles polling and evaluation for the lifetime of the process.

***

## (1) Install the SDK

Choose your language, then install the WorkOS SDK with your package manager.

:::code-group{title="Install the WorkOS SDK"}

```bash language="js"
npm install @workos-inc/node
```

```bash language="python"
pip install workos
```

:::

***

## (2) Set secrets

To make calls to WorkOS, provide your API key. Store it as a managed secret, such as `WORKOS_API_KEY`, and pass it to the SDK through your application's environment.

```plain title="Environment variables"
WORKOS_API_KEY='sk_example_123456789'
```

> This runtime client is for trusted server-side environments only. Never expose your WorkOS API key in client-side code.

***

## (3) Basic setup

Initialize the WorkOS client, create a runtime client, and evaluate flags once the client is ready.

:::code-group

```js language="js"
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 poll failed:', error.message);
});

try {
  await client.waitUntilReady({ timeoutMs: 5000 });

  const isEnabled = client.isEnabled('advanced-analytics', {
    organizationId: 'org_01EHQMYV6MBK39QC5PZXHY59C3',
  });

  console.log('Feature enabled:', isEnabled);
} catch (error) {
  console.error('Runtime client failed to initialize:', error);
}
```

```py language="python"
from workos import AuthenticationError, WorkOSClient

# Reads WORKOS_API_KEY from the environment when api_key isn't passed
workos = WorkOSClient()

flags = workos.create_feature_flags_runtime_client()
# Start polling in the background; wait_until_ready() doesn't start it
flags.start()

try:
    flags.wait_until_ready(timeout=5)
except (TimeoutError, AuthenticationError) as error:
    print("Runtime client failed to initialize:", error)

is_enabled = flags.is_enabled(
    "advanced-analytics", {"organization": "org_01EHQMYV6MBK39QC5PZXHY59C3"}
)
print("Feature enabled:", is_enabled)
```

:::

The client is ready once bootstrap data is provided or its first poll succeeds.

The client starts polling as soon as you create it. Before it's ready, `waitUntilReady()` rejects on a 401 or once `timeoutMs` passes. Without `timeoutMs`, it waits through any number of other failed polls. The example uses top-level `await`. Where that is not available, read [Without top-level await](#without-top-level-await).

`start()` returns immediately and polls on a background thread: it sends the first poll right away, then one every polling interval. If the client isn't ready within `timeout` seconds, `wait_until_ready()` raises `TimeoutError`. If the API key is rejected first, it raises `AuthenticationError`.

In a server that forks worker processes, create one runtime client per worker, as described in [Running under gunicorn or uWSGI](#running-under-gunicorn-or-uwsgi).

Checking a flag returns whether it's enabled, based on the flag's configuration and any targeting rules that match the provided context. Evaluation happens locally and never throws, so you can call it before the client is ready.

When a poll fails, the client keeps serving the last flags it received and keeps polling, waiting longer after repeated failures, as described in [Configuration options](#configuration-options). A 401 stops polling. To find out when a poll fails, register an error listener, as described in [Event handling](#event-handling).

The client also logs failures to the `workos.feature_flags.runtime` logger.

***

## (4) Context-based evaluation

Pass an organization ID, a user ID, or both to evaluate flags against the targeting rules configured in the dashboard. To target resources from your own application, such as workspaces, read [Custom targets](https://workos.com/docs/feature-flags/custom-targets).

:::code-group

```js language="js"
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 poll failed:', error.message);
});

await client.waitUntilReady({ timeoutMs: 5000 });

// 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',
});

// Evaluate with both organization and user targeting. The first rule that
// matches either one decides the result.
const isEnabledForBoth = client.isEnabled('advanced-analytics', {
  organizationId: 'org_01EHQMYV6MBK39QC5PZXHY59C3',
  userId: 'user_01E4ZCR3C56J083X43JQXF3JK5',
});

// Return true instead of false when the flag doesn't exist
const isEnabledOrTrue = client.isEnabled('advanced-analytics', {}, true);

// Evaluate every flag for one context
const allFlags = client.getAllFlags({
  organizationId: 'org_01EHQMYV6MBK39QC5PZXHY59C3',
});

// Inspect a flag's configuration for debugging; undefined if it doesn't exist
const flag = client.getFlag('advanced-analytics');
```

```py language="python"
from workos import WorkOSClient

workos = WorkOSClient()

flags = workos.create_feature_flags_runtime_client()
flags.start()
flags.wait_until_ready(timeout=5)

# Evaluate with organization targeting. Context keys are target types, such
# as "organization" and "user", not "organization_id" or "user_id".
is_enabled = flags.is_enabled(
    "advanced-analytics", {"organization": "org_01EHQMYV6MBK39QC5PZXHY59C3"}
)

# Evaluate with user targeting
is_enabled_for_user = flags.is_enabled(
    "advanced-analytics", {"user": "user_01E4ZCR3C56J083X43JQXF3JK5"}
)

# Evaluate with both organization and user targeting. The first rule that
# matches either one decides the result.
is_enabled_for_both = flags.is_enabled(
    "advanced-analytics",
    {
        "organization": "org_01EHQMYV6MBK39QC5PZXHY59C3",
        "user": "user_01E4ZCR3C56J083X43JQXF3JK5",
    },
)

# Entries set to None are skipped, so optional IDs can be passed directly
is_enabled_without_organization = flags.is_enabled(
    "advanced-analytics",
    {"organization": None, "user": "user_01E4ZCR3C56J083X43JQXF3JK5"},
)

# Return True instead of False when the flag doesn't exist or the client
# isn't ready yet
is_enabled_or_true = flags.is_enabled("advanced-analytics", default=True)

# Evaluate every flag for one context
all_flags = flags.get_all_flags({"organization": "org_01EHQMYV6MBK39QC5PZXHY59C3"})

# Inspect a flag's configuration for debugging; None if it doesn't exist
flag = flags.get_flag("advanced-analytics")
```

:::

You can also key each resource by its target type instead, as an object with the resource's `id`, such as `{ organization: { id: 'org_123' } }`. The two forms cannot be mixed in one call. A context that mixes them matches no targets, so the flag returns its `default_value`, and TypeScript does not catch it. Read [Custom targets](https://workos.com/docs/feature-flags/custom-targets#3-evaluate-the-flag-in-your-application) for details.

The client logs context warnings only to your [`logger`](#configuration-options), so without one they are silent. A `userId` or `organizationId` that is not a non-empty string is ignored without a warning.

The context is a dictionary that maps each target type, such as `organization`, `user`, or `workspace`, to the exact, case-sensitive ID being evaluated. There is no `user_id` or `organization_id` form. A key like `user_id` is treated as a custom target type, and `userId` is ignored because target types are lowercase, so neither matches your user targeting. The client logs a warning that names the key to use instead, and logs each distinct context problem only once.

Invalid context never causes an error. The client ignores the invalid entry, logs a warning, and evaluates the rest of the context.

The client checks a flag's rules in order, and the first rule that matches the context decides the result. If a rule that serves `false` for a user comes before a rule that serves `true` for their organization, evaluating with both IDs returns `false`. If no rule matches, the client returns the flag's `default_value`. A flag that is turned off returns `false`.

If no context is provided, no rule can match, so an enabled flag returns its `default_value`. If the flag does not exist, the client returns `false`. To return a different value, pass your own default, as the example shows.

You can also evaluate every flag for one context at once. The result maps flag slugs to their results, and is empty before the client is ready.

To see how a flag is configured, read it from the client. You get its configuration as the client received it, with its rules in evaluation order. Use it for debugging, and evaluate the flag for decisions.

***

## (5) Graceful shutdown

Close the client when your application shuts down to stop background polling and clean up resources. Evaluation keeps serving the last flags after the client is closed.

:::code-group

```js language="js"
import { WorkOS } from '@workos-inc/node';

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

const client = workos.featureFlags.createRuntimeClient();

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

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

```py language="python"
import atexit

from workos import WorkOSClient

workos = WorkOSClient()

flags = workos.create_feature_flags_runtime_client()
flags.start()

# Stop polling when the process exits
atexit.register(flags.close)
```

:::

`close()` waits up to the request timeout for a poll in progress to finish. If you pass your own `http_client` to `WorkOSClient`, close the runtime client before you close that HTTP client. For a script or job with a clear end, use the runtime client as a [context manager](#context-manager) instead.

***

## Event handling

The runtime client notifies your code of state changes during its lifecycle. Register a listener for each notification you need.

:::code-group

```js language="js"
import { WorkOS } from '@workos-inc/node';

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

const client = workos.featureFlags.createRuntimeClient();

// Fires when a flag's configuration changes after the first successful poll
client.on('change', ({ key, previous, current }) => {
  console.log(`Flag "${key}" changed`, { previous, current });
});

// Fires when a poll fails; the client keeps its last flags and retries,
// except after a 401
client.on('error', (error) => {
  console.error('Feature flag poll failed:', error.message);
});

// Fires on 401 Unauthorized — polling stops automatically
client.on('failed', (error) => {
  console.error('Client failed:', error.message);
});
```

```py language="python"
import logging

from workos import AuthenticationError, WorkOSClient

logger = logging.getLogger(__name__)

workos = WorkOSClient()


# Called after a refresh installs a different configuration, including the
# first load, when every flag counts as changed
def on_change(event):
    logger.info("Feature flags changed: %s", sorted(event.changed_flags))


# Called when a background poll fails; the client keeps its last flags and
# retries, except after a 401
def on_error(error):
    if isinstance(error, AuthenticationError):
        logger.error("Feature flag polling stopped: %s", error)
    else:
        logger.warning("Feature flag poll failed: %s", error)


flags = workos.create_feature_flags_runtime_client(
    on_change=on_change,
    on_error=on_error,
)

# Listeners can also be added after the client is created. Each
# add_*_listener() method has a matching remove_*_listener().
flags.add_status_listener(
    lambda status: logger.debug("Feature flags refreshed: %d flags", status.flag_count)
)

flags.start()
```

:::

| Event    | When it fires                                                                                                                                                                                                                                                                                  |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `change` | A flag's configuration changes after the first successful poll. The listener receives `{ key, previous, current }`, where `previous` and `current` are the flag's `FlagPollEntry` (`slug`, `enabled`, `default_value`, `off_value`, and `rules`), or `null` when the flag was added or removed |
| `error`  | A poll fails, including when the response is a payload the SDK cannot read. The client keeps its last flags and retries automatically, except after a 401, which also fires `failed`                                                                                                           |
| `failed` | A poll receives a 401 Unauthorized response. Polling stops automatically                                                                                                                                                                                                                       |

Replacing bootstrap data with the first poll's data does not fire `change`.

Don't throw from a listener. An exception thrown in an `error` listener stops polling and is reported as an `unhandledRejection`, which ends the Node process by default. An exception thrown in a `change` listener is reported as an `error`, and the rest of that poll's `change` events are not delivered.

There is no status event. To check on the client at any time, call `getStats()` for poll counts, timestamps, `flagCount`, and `cacheAge`, the milliseconds since the last successful poll.

Pass listener functions when you create the client, or add them later with `add_change_listener()`, `add_error_listener()`, and `add_status_listener()`.

| Listener    | When it's called                                                                                                                                                                                                                                                                                                            |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `on_change` | After a refresh installs a configuration that differs from the previous one, including the first load. It receives a `FlagsChangedEvent` with the `previous` and `current` configurations and `changed_flags`, the slugs that were added, removed, or modified. `previous` is `None` on a first load without bootstrap data |
| `on_error`  | When a background poll fails, including when the response is a payload the SDK cannot read. It receives the exception. The client keeps its last flags and retries, except after a 401: then `on_error` receives an `AuthenticationError` and polling stops                                                                 |
| `on_status` | After every refresh, successful or not, and when polling stops after a 401. It receives the client's new `RuntimeStatus`, which has `polling_stopped` set to `True` once polling stops                                                                                                                                      |

Listeners run one at a time, in refresh order. An exception raised in a listener is logged and never affects polling. The runtime client from `WorkOSClient` calls listeners synchronously, so passing an `async def` function as a listener, whether when you create the client or later, raises `TypeError`. For coroutine listeners, use the [async client](#async-client).

To check on the client at any time, read `flags.status`. It returns a `RuntimeStatus` with `ready`, `last_error`, `polling_stopped`, `flag_count`, refresh counts and timestamps, and `cache_age()`, the seconds since the last successful refresh.

***

## Bootstrap configuration

Pre-populate the client with flag data so evaluations are available immediately, before the first poll completes. A client created with bootstrap data is ready right away. Bootstrap data is replaced with live data after the first successful poll.

:::code-group

```js language="js"
import { WorkOS } from '@workos-inc/node';

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

const client = workos.featureFlags.createRuntimeClient({
  bootstrapFlags: {
    version: 2,
    flags: {
      'advanced-analytics': {
        slug: 'advanced-analytics',
        enabled: true,
        default_value: false,
        off_value: false,
        rules: [
          {
            id: 'flag_rule_01JA8GJZ8M8KY1W7R3M8P8XG2N',
            kind: 'conditions',
            value: true,
            conditions: [
              {
                target_type: 'organization',
                operator: 'one_of',
                values: ['org_01EHQMYV6MBK39QC5PZXHY59C3'],
              },
            ],
          },
        ],
      },
    },
  },
});

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

// Flags are available immediately from the bootstrap data
const isEnabled = client.isEnabled('advanced-analytics', {
  organizationId: 'org_01EHQMYV6MBK39QC5PZXHY59C3',
});
console.log('Feature enabled:', isEnabled);

// Live data replaces the bootstrap data after the first successful poll
```

```py language="python"
from pathlib import Path

from workos import FeatureFlagsConfiguration, WorkOSClient
from workos.feature_flags.runtime import FeatureFlagsConfigurationError

cache = Path("feature-flags.json")

bootstrap = None
try:
    bootstrap = FeatureFlagsConfiguration.from_json(cache.read_text())
except (OSError, FeatureFlagsConfigurationError):
    # No usable cache yet: the client becomes ready after its first poll
    pass


# Persist every new configuration for the next startup
def save_configuration(event):
    cache.write_text(event.current.to_json())


workos = WorkOSClient()

flags = workos.create_feature_flags_runtime_client(
    bootstrap=bootstrap,
    on_change=save_configuration,
)
flags.start()

# With a cached configuration, flags are available immediately
is_enabled = flags.is_enabled(
    "advanced-analytics", {"organization": "org_01EHQMYV6MBK39QC5PZXHY59C3"}
)
print("Feature enabled:", is_enabled)
```

:::

The bootstrap data uses the same `{ version: 2, flags }` shape the client polls, with each flag keyed by its slug. If the data has any other shape, or any flag in it is malformed, the client ignores all of it and logs a warning to your `logger`, if you passed one. The client is still ready right away, but with no flags, so every check returns `false` or your default until its first successful poll.

The example shows a common pattern: cache each configuration the client receives, and pass the cached copy back as bootstrap data when your application restarts. `from_json()` validates the data and raises `FeatureFlagsConfigurationError` if it isn't a configuration the SDK can read, so a corrupt cache fails when you load it rather than when you evaluate.

To build a configuration from a dictionary in the same `{"version": 2, "flags": ...}` shape the client polls, with each flag keyed by its slug, use `FeatureFlagsConfiguration.from_dict()`. To fetch a configuration on demand instead of polling, read [Manual refresh](#manual-refresh).

***

## Configuration options

Pass options when you create the runtime client to customize its behavior.

| Option              | Description                                                                                                                                                |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pollingIntervalMs` | How often to poll for flag changes, as a `number` of milliseconds, with 10% jitter. Defaults to 30 seconds. Values below 5 seconds are raised to 5 seconds |
| `requestTimeoutMs`  | Time limit for each poll, including the SDK's retries of transient failures, as a `number` of milliseconds. Defaults to 10 seconds                        |
| `bootstrapFlags`    | Pre-populated flag data, as a `FlagPollResponse`, for instant evaluation before the first poll. Not set by default                                         |
| `logger`            | A custom `RuntimeClientLogger` with `debug`, `info`, `warn`, and `error` methods. Not set by default                                                       |

After a failed poll, the client waits for the polling interval or the backoff delay, whichever is longer, with the same 10% jitter. The backoff starts at 1 second and doubles with each failure in a row, up to 60 seconds.

Polling uses the `WorkOS` client's API key, API hostname, and `maxRetries` setting.

| Argument           | Description                                                                                                                                          |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `polling_interval` | How often to poll for flag changes, as a `float` of seconds, with 10% jitter. Defaults to 30 seconds. Positive values below 5 seconds are raised to 5 seconds |
| `request_timeout`  | Time limit for each refresh, including retries of transient failures, as a `float` of seconds. Defaults to 10 seconds                                         |
| `max_retry_delay`  | Upper bound for the backoff delay after failed polls, as a `float` of seconds. Defaults to 60 seconds                                                         |
| `bootstrap`        | Pre-populated flag data, as a `FeatureFlagsConfiguration`, for instant evaluation before the first poll. Not set by default                          |
| `logger`           | A `logging.Logger` for failures and ignored context. Defaults to the `workos.feature_flags.runtime` logger                                           |
| `on_change`        | Called with a `FlagsChangedEvent` after a refresh installs a different configuration                                                                 |
| `on_error`         | Called with the exception when a background poll fails                                                                                               |
| `on_status`        | Called with a `RuntimeStatus` after every refresh, and when polling stops                                                                            |

`polling_interval`, `request_timeout`, and `max_retry_delay` must be positive numbers. Zero, a negative value, or a value that isn't finite raises `ValueError`.

After a failed poll, the client waits for the polling interval or the backoff delay, whichever is longer, with the same 10% jitter. The backoff starts at 1 second and doubles with each failure in a row, up to `max_retry_delay`. After a 429, it waits at least as long as the response's `Retry-After` asks, up to 60 seconds.

Polling uses the `WorkOSClient`'s API key, base URL, and `max_retries` setting.

***

## Node.js SDK

These sections apply to [`@workos-inc/node`](https://workos.com/docs/sdks/node) only.

### Without top-level await

The [basic setup](#3-basic-setup) example uses top-level `await`. Where that is not available, use the promise that `waitUntilReady()` returns:

:::code-group

```js language="js"
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 poll 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);
  });
```

:::

### Next.js with AuthKit

If you're using `@workos-inc/authkit-nextjs`, use `getFeatureFlagsRuntimeClient()` instead of creating the runtime client directly. The helper returns one shared runtime client for the server process, so repeated calls across routes or server components do not create extra polling clients. It uses the `@workos-inc/node` version installed alongside `@workos-inc/authkit-nextjs`. `@workos-inc/authkit-nextjs` `4.6.0` and later accept `@workos-inc/node` `9` through `12`, and earlier releases accept only `9` and `10`. Install `@workos-inc/node` `12.0.0` or later. Earlier versions ignore rules that serve `false`, and a failed poll with no `error` listener stops all further polls and can crash the process. The helper requires `WORKOS_API_KEY` and throws without it. Options you pass are used only by the first call, which creates the client.

```tsx
import {
  getFeatureFlagsRuntimeClient,
  withAuth,
} from '@workos-inc/authkit-nextjs';

const featureFlags = getFeatureFlagsRuntimeClient();

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

export default async function DashboardPage() {
  const { user, organizationId } = await withAuth({ ensureSignedIn: true });

  try {
    await featureFlags.waitUntilReady({ timeoutMs: 5000 });
  } catch (error) {
    console.error('Feature flags client failed to initialize:', error);
  }

  const isEnabled = featureFlags.isEnabled('advanced-analytics', {
    userId: user.id,
    organizationId,
  });

  return isEnabled ? <AdvancedAnalytics /> : <BasicAnalytics />;
}
```

### TypeScript types

If you're using TypeScript, the `@workos-inc/node` package includes built-in type definitions. To learn more about the specific configuration options and types available for the runtime client, read the [`@workos-inc/node` package docs](https://www.npmjs.com/package/@workos-inc/node). Key exports include `RuntimeClientOptions`, `EvaluationContext`, `FeatureFlagsRuntimeClient`, `RuntimeClientStats`, `RuntimeClientLogger`, `FlagPollResponse`, `FlagPollEntry`, and `FlagChange`, the type of the `change` event.

***

## Python SDK

These sections apply to the [`workos`](https://workos.com/docs/sdks/python) Python package only.

### Context manager

For a script or job with a clear end, use the runtime client as a context manager instead of calling `start()` and `close()` yourself. Entering the `with` block starts polling, and leaving it closes the client.

```python
with workos.create_feature_flags_runtime_client() as flags:
    flags.wait_until_ready(timeout=5)
    run_job(flags)
```

### Manual refresh

`refresh()` fetches the configuration immediately, on the calling thread. It works without `start()`, so a script or serverless function can fetch flags when it needs them instead of polling in the background.

:::code-group

```py language="python"
from workos import WorkOSClient, WorkOSError

workos = WorkOSClient()

# Created once per container and reused across warm invocations. A short
# timeout keeps a failed refresh from holding up the invocation.
flags = workos.create_feature_flags_runtime_client(request_timeout=2)


def handler(event, context):
    # Fetch flags on a cold start, and again once they're more than 30 seconds
    # old. After a failed refresh, the next invocation tries again.
    cache_age = flags.status.cache_age()
    if cache_age is None or cache_age > 30:
        try:
            flags.refresh()
        except WorkOSError as error:
            # Evaluation keeps serving the last flags, or your defaults
            print("Feature flag refresh failed:", error)

    is_enabled = flags.is_enabled(
        "advanced-analytics", {"organization": event["organization_id"]}
    )
    return {"advanced_analytics": is_enabled}
```

:::

Unlike a background poll, `refresh()` raises when it fails and does not call `on_error`. The client keeps its last configuration. Transient failures, such as timeouts, rate limits, and server errors, are retried within the refresh before it raises. A 401 also stops background polling.

### Async client

With `AsyncWorkOSClient`, `create_feature_flags_runtime_client()` returns an async runtime client that polls in an asyncio task on the running event loop. Await `start()`, `wait_until_ready()`, `refresh()`, and `close()`, or use the client with `async with`. Evaluation stays synchronous, so `is_enabled()`, `get_all_flags()`, and `get_flag()` are called without `await`.

:::code-group

```py language="python"
import asyncio

from workos import AsyncWorkOSClient, AuthenticationError


async def main():
    workos = AsyncWorkOSClient()
    try:
        # Starts polling on entry and closes the runtime client on exit
        async with workos.create_feature_flags_runtime_client() as flags:
            try:
                await flags.wait_until_ready(timeout=5)
            except (TimeoutError, AuthenticationError) as error:
                print("Runtime client failed to initialize:", error)

            # Evaluation is synchronous
            is_enabled = flags.is_enabled(
                "advanced-analytics", {"organization": "org_01EHQMYV6MBK39QC5PZXHY59C3"}
            )
            print("Feature enabled:", is_enabled)
    finally:
        await workos.close()


asyncio.run(main())
```

:::

Use the runtime client on the same event loop as its `AsyncWorkOSClient`, and close it before you close the `AsyncWorkOSClient`. In a web framework, start it in your application's startup or lifespan hook and close it on shutdown. Listeners can be plain functions or coroutine functions.

### Running under gunicorn or uWSGI

Servers such as gunicorn and uWSGI run your application in worker processes forked from a parent process. Each worker needs its own runtime client, and the most reliable way to get one is to create the `WorkOSClient` and the runtime client in the worker:

- Without `--preload`, gunicorn imports your application in each worker after forking, so a client created and started at import time already belongs to that worker. uWSGI does the same with `--lazy-apps`.
- With `--preload`, create the clients in gunicorn's `post_fork` hook, which runs in each worker after it is forked.
- uWSGI loads your application before forking unless you pass `--lazy-apps`, so run it with `--lazy-apps`.

:::code-group

```py language="python" title="myapp/feature_flags.py" tab="python-1"
from workos import WorkOSClient

_flags = None


def start():
    """Create and start the runtime client for the current process."""
    global _flags
    _flags = WorkOSClient().create_feature_flags_runtime_client()
    _flags.start()


def is_enabled(slug, context=None, default=False):
    if _flags is None:
        return default
    return _flags.is_enabled(slug, context, default=default)
```

```py language="python" title="gunicorn.conf.py" tab="python-2"
from myapp import feature_flags

preload_app = True


# Runs in each worker right after gunicorn forks it from the parent
def post_fork(server, worker):
    feature_flags.start()
```

:::

Except on macOS, a runtime client created in the parent before the fork can also be started in each worker, and a `WorkOSClient` runtime client that was already polling resumes in each worker the first time it's used there. This relies on Python's fork hooks, which gunicorn runs. uWSGI runs them only with `--py-call-uwsgi-fork-hooks`. The runtime client can't poll in a worker if the parent already sent requests through an HTTP client you passed to `WorkOSClient`, or through an `AsyncWorkOSClient`. In that case, `start()` and `refresh()` raise `RuntimeError`, and evaluation keeps serving the last configuration.

On macOS, a forked process can crash when it opens a network connection once its parent has created an HTTP client, so a runtime client created before the fork never polls in a worker. `start()` logs a warning instead of raising, `refresh()` raises `RuntimeError`, and evaluation serves the bootstrap or last configuration, or your defaults. Create the clients after forking, or run without `--preload` locally.

The runtime client polls on a background thread. uWSGI versions before 2.0.27 don't run Python threads by default, so run them with `--enable-threads`.

### Type annotations

The `workos` package ships with type annotations for type checkers such as mypy and pyright. Key exports include `FeatureFlagsRuntimeClient`, `AsyncFeatureFlagsRuntimeClient`, and `FeatureFlagsConfiguration` from `workos`, and `EvaluationContext`, `FlagsChangedEvent`, `RuntimeStatus`, `FlagDefinition`, and `FeatureFlagsConfigurationError` from `workos.feature_flags.runtime`.

***

## Common issues

- If all flags return their default values, the client may not be ready yet, the flag key may be incorrect, or no targeting rules may match the evaluation context.
- If your error listener is called, a poll failed, for example because of a network error or a response the SDK cannot read. The client keeps serving the last flags it received and retries automatically in the background, unless the error was a 401.
- If dashboard changes do not appear immediately, remember that the runtime client refreshes on the configured polling interval.

* If the client emits `failed`, your API key is invalid or unauthorized, and polling stops until the client is recreated with valid credentials.
* If your `error` listener receives `ApiKeyRequiredException`, the `WorkOS` client has no API key. Unlike a 401, this doesn't stop polling, and `waitUntilReady()` rejects only once `timeoutMs` passes.

- If all flags return their default values, also check that `start()` was called, because `wait_until_ready()` does not start polling.
- If user or organization targeting never matches, check the context keys. Use `user` and `organization`, not `user_id` or `userId`.
- If `wait_until_ready()` raises `AuthenticationError`, or `flags.status.polling_stopped` is `True`, your API key is invalid or unauthorized, and polling stops until the client is recreated with valid credentials.
- If `WorkOSClient()` raises `ValueError`, or creating a runtime client raises `ConfigurationError`, the `WorkOSClient` has no API key. Set `WORKOS_API_KEY` or pass `api_key`.
- If registering a listener raises `TypeError`, the listener is an `async def` function. Use a plain function with `WorkOSClient`, or use the [async client](#async-client).
- If worker processes never receive flag changes, read [Running under gunicorn or uWSGI](#running-under-gunicorn-or-uwsgi).
