Runtime client
Evaluate feature flags in a server-side application with the WorkOS runtime client.
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/nodeversion12.0.0or later. Earlier versions ignore rules that servefalse, so they can returntruewhere12.0.0returnsfalse.- The
workosPython package version10.8.0or later.
Choose your language in 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.
Choose your language, then install the WorkOS SDK with your package manager.
Don't see an SDK you need? Contact us to request an SDK!
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.
Initialize the WorkOS client, create a runtime client, and evaluate flags once the client is ready.
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.
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.
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. A 401 stops polling. To find out when a poll fails, register an error listener, as described in Event handling.
The client also logs failures to the workos.feature_flags.runtime logger.
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.
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 for details.
The client logs context warnings only to your logger, 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.
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.
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 instead.
The runtime client notifies your code of state changes during its lifecycle. Register a listener for each notification you need.
| 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.
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.
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.
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.
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.
These sections apply to @workos-inc/node only.
The basic setup example uses top-level await. Where that is not available, use the promise that waitUntilReady() returns:
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.
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. Key exports include RuntimeClientOptions, EvaluationContext, FeatureFlagsRuntimeClient, RuntimeClientStats, RuntimeClientLogger, FlagPollResponse, FlagPollEntry, and FlagChange, the type of the change event.
These sections apply to the workos Python package only.
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.
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.
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.
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.
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.
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’spost_forkhook, 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.
| 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) |
| 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.
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.
- 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
errorlistener receivesApiKeyRequiredException, theWorkOSclient has no API key. Unlike a 401, this doesn’t stop polling, andwaitUntilReady()rejects only oncetimeoutMspasses.
- If all flags return their default values, also check that
start()was called, becausewait_until_ready()does not start polling. - If user or organization targeting never matches, check the context keys. Use
userandorganization, notuser_idoruserId. - If
wait_until_ready()raisesAuthenticationError, orflags.status.polling_stoppedisTrue, your API key is invalid or unauthorized, and polling stops until the client is recreated with valid credentials. - If
WorkOSClient()raisesValueError, or creating a runtime client raisesConfigurationError, theWorkOSClienthas no API key. SetWORKOS_API_KEYor passapi_key. - If registering a listener raises
TypeError, the listener is anasync deffunction. Use a plain function withWorkOSClient, or use the async client. - If worker processes never receive flag changes, read Running under gunicorn or uWSGI.