JavaScript SDK
@getexperimently/js-sdk (v0.1.0) is the JavaScript/TypeScript client for the
Experimently public API: experiment assignment, feature flag evaluation and event
tracking for Node >= 18 and browsers. Zero runtime dependencies — it uses the global fetch.
By default, flag evaluation and experiment assignment are decided by the server: every call
goes to the public API with your X-API-Key, the server buckets the user (sticky per user +
experiment), and the SDK caches the answer per user + key. In server-side code you can opt in to
local evaluation (evaluation: 'local'): flags are then
answered in-process from the server's ruleset, with the server's answers, and experiments stay on
the server.
Source: sdk/js. For React apps use the React SDK; for edge runtimes the
Edge SDK; for the vendor-neutral API the OpenFeature provider, which
delegates to this package.
CI runs this SDK's contract smoke against a real API: js is in the sdks: list of the
SDK Live Contract job in pr-qa-gate.yml.
Installation
The @getexperimently/js-sdk package is on npm.
0.1.0 is a beta release.
To work on the SDK itself, build it from a clone of this repository instead. npm pack writes
getexperimently-js-sdk-0.1.0.tgz:
git clone https://github.com/getexperimently/experimently.git
cd experimently/sdk/js
npm ci && npm run build && npm pack
Then, in your app, install the packed tarball:
CommonJS build (dist/index.js) with type declarations; import/require both work.
Quick start
import { ExperimentationClient } from '@getexperimently/js-sdk';
const client = new ExperimentationClient({
apiUrl: 'http://localhost:8000', // origin only; the SDK appends /api/v1/...
apiKey: process.env.EXPERIMENTLY_API_KEY!,
});
const user = { userId: 'user-123', attributes: { country: 'US', plan: 'pro' } };
// Sticky experiment assignment (POST /api/v1/tracking/assign); attributes are sent as `context`
const assignment = await client.getAssignment('checkout_button_color', user);
assignment.variantName; // 'control' | 'treatment'
assignment.configuration; // the variant's configuration object, or null
// Or just the name — 'control' (defaultVariant) when assignment fails
const variant = await client.getVariant('checkout_button_color', user);
// Feature flag (GET /api/v1/feature-flags/evaluate/{key}?user_id=...&context=<url-encoded attributes>)
const { key, enabled, config, reason } = await client.evaluateFlag('dark_mode', user);
const isEnabled = await client.isFeatureEnabled('dark_mode', user); // false on failure
// Track a conversion for one experiment (POST /api/v1/tracking/track)
await client.track('user-123', 'purchase', { value: 49.99, experimentKey: 'checkout_button_color' });
// Track without a key: fans out to every cached assignment and flag for the user (see below)
await client.track('user-123', 'page_view', { properties: { page: '/checkout' } });
The block is TypeScript and uses top-level await, so it runs as an ES module. To run it as it
is, save it as quick-start.mts in the project where you installed the package, set
EXPERIMENTLY_API_KEY, and run it with node --experimental-strip-types quick-start.mts
(Node 22.6 or later; it may print an ExperimentalWarning). As plain JavaScript, delete the ! after
process.env.EXPERIMENTLY_API_KEY, save it as quick-start.mjs and run node quick-start.mjs.
Configuration (ClientConfig)
const client = new ExperimentationClient({
apiUrl: 'https://api.example.com', // Required. Backend origin; trailing slashes are stripped.
apiKey: 'eptk_...', // Required. Sent as X-API-Key.
timeoutMs: 5000, // Per-request timeout (default 5000)
cacheTtlMs: 300_000, // Successful evaluations/assignments reused per user + key (default 5 min)
defaultVariant: 'control', // Returned by getVariant when assignment fails (default 'control')
fetch: customFetch, // Optional custom fetch (tests, polyfills); defaults to globalThis.fetch
onError: (err, operation) => {}, // Optional; called when track / trackBatch (or, in local mode, refresh / flush / evaluate) swallow a failure
evaluation: 'server', // 'server' (default) or 'local' (server-side only; see Local evaluation)
});
| Field | Type | Default | Description |
|---|---|---|---|
apiUrl |
string |
— | Backend origin, e.g. https://api.example.com; the SDK appends /api/v1/... |
apiKey |
string |
— | API key, sent as X-API-Key |
timeoutMs |
number |
5000 |
Per-request timeout (AbortController); also caps the 429 retry delay |
cacheTtlMs |
number |
300000 |
TTL of successful evaluations/assignments, per user + key |
defaultVariant |
string |
'control' |
Variant name getVariant returns on failure |
fetch |
typeof fetch |
global fetch |
Custom fetch implementation |
onError |
(error: ExperimentationError, op: 'track' \| 'trackBatch' \| 'refresh' \| 'flush' \| 'evaluate') => void |
— | Only way to observe swallowed failures; a throwing handler is itself swallowed. 'refresh', 'flush' and 'evaluate' occur only in local mode |
evaluation |
'server' \| 'local' |
'server' |
'local': answer flags in-process from GET /api/v1/sdk/ruleset; needs a key with the sdk:ruleset scope; throws in a browser |
refreshIntervalMs |
number |
30000 |
Local mode: ruleset refresh interval (minimum 5000, ±10% jitter) |
maxStaleMs |
number |
none | Local mode: evaluate on the server once the ruleset has not been refreshed for this long |
The constructor throws a plain Error (apiKey is required / apiUrl is required) when either
required field is missing, when evaluation is not 'server' or 'local', and when
evaluation: 'local' is used in a browser.
UserContext
interface UserContext {
userId: string; // stable id; the server buckets on it
attributes?: Record<string, unknown>; // sent as `context` on assignment and on flag evaluation (targeting rules)
}
Targeting context
attributes is what the platform's targeting rules evaluate against. It is sent as context in
the POST /api/v1/tracking/assign body and, when it is a non-empty object, as
context=<url-encoded JSON> on GET /api/v1/feature-flags/evaluate/{flagKey} (and on
getAllFlags(userId, attributes)), so a flag whose dashboard rule says
os_version semver_gte 17.0.0 AND tier equals premium turns on only for matching users. Top-level
keys are also reachable under user. / device. / app. aliases in rules (country matches
user.country), and nested objects flatten to dotted keys ({ app: { version: '3.2.1' } }
answers app.version).
Attributes are assumed stable per user: evaluations and assignments are cached by user + key
only, so call clearCache() after changing a user's attributes.
getAssignment and evaluateFlag reject with ExperimentationError (code: 'INVALID_RESPONSE',
user.userId is required) when userId is missing or empty; getVariant / isFeatureEnabled
turn that into their safe default.
API
| Method | Signature | Returns | On failure |
|---|---|---|---|
getAssignment |
(experimentKey, user: UserContext) => Promise<Assignment> |
{ experimentKey, userId, variantId, variantName, isControl, configuration, assigned?, reason? } |
Rejects with ExperimentationError (404 when the experiment is not ACTIVE) |
getVariant |
(experimentKey, user) => Promise<string> |
assignment.variantName |
defaultVariant ('control'); never rejects |
evaluateFlag |
(flagKey, user) => Promise<FlagEvaluation> |
{ key, enabled, config, reason? } (user.attributes sent as context) |
Rejects with ExperimentationError (404 for an unknown key); a flag that exists but is not ACTIVE resolves enabled: false, reason: "inactive" |
isFeatureEnabled |
(flagKey, user) => Promise<boolean> |
evaluation.enabled |
false; never rejects |
getAllFlags |
(userId, attributes?) => Promise<Record<string, boolean>> |
{ flagKey: enabled }; attributes sent as context; not cached, not part of the fan-out |
Rejects |
track |
(userId, eventName, options?: TrackOptions) => Promise<void> |
— | Never rejects; onError(err, 'track') |
trackBatch |
(events: TrackEvent[]) => Promise<BatchResult> |
{ successCount, failureCount, errors } |
Never rejects; a failed chunk counts all its events as failures and calls onError(err, 'trackBatch') |
fetchAssignments |
(userId, { activeOnly?: boolean } = { activeOnly: true }) => Promise<AssignmentRecord[]> |
Server-side assignment rows (raw objects); not cached | Rejects |
getAssignments |
(userId) => Assignment[] |
Cached, unexpired assignments in assignment order (no network) | — |
getEvaluatedFlags |
(userId) => string[] |
Keys of cached, successfully evaluated flags (no network) | — |
clearCache |
() => void |
Drops both caches | — |
ready |
({ timeoutMs? }?) => Promise<ReadyResult> |
{ ok: true, rulesetVersion } or { ok: false, error }; { ok: true, rulesetVersion: null } at once in server mode |
Never rejects |
status |
() => LocalEvaluationStatus |
{ evaluation, ready, rulesetVersion, lastRefreshAt, lastError, serverEvaluatedFlags } |
— |
close |
() => Promise<void> |
Local mode: stops the ruleset refresh and sends the remaining evaluation counts; later calls are evaluated by the server. A no-op in server mode | Never rejects |
Enrolment: assigned and reason
An Assignment also carries assigned and reason. assigned: false means the server did not
enrol the user (reason is 'holdout', 'mutual_exclusion' or 'targeting') and returned the
control variant so you render the default experience; no exposure was recorded. When you export
exposures to a warehouse, exclude assignments with assigned: false: the user was not enrolled,
and reason says why. A server that predates the fields sends neither, so assigned is
undefined (never false). Decide deliberately how to treat those rows rather than dropping them
with an assigned === true filter.
TrackOptions: value? (number), properties? (sent as metadata), experimentKey?,
featureFlagKey?, eventType? (defaults to the event name), timestamp? (Date or string, sent as
ISO-8601). TrackEvent = TrackOptions & { userId, eventName }.
Tracking and the fan-out rule
- With
experimentKeyand/orfeatureFlagKey: exactly onePOST /api/v1/tracking/track. - Without a key: one
POST /api/v1/tracking/batch(chunked at 100) containing one entry per experiment the user has been assigned to in this client (experiment_key) plus one per flag evaluated for the user (feature_flag_key), taken from the cache. If nothing is cached, nothing is sent. This is what makes a singletrack(userId, 'purchase', …)count as a conversion for every experiment the user is in. trackBatchapplies the same expansion to keyless entries, then sends everything in chunks of 100 and sums the server'ssuccess_count/failure_count/errors.
Conversions are matched to metrics by event name.
Caching, dedupe and retries
- Successful assignments and evaluations are cached per user + key for
cacheTtlMs; failures are never cached, so the next call retries. - Concurrent calls for the same user + key share one in-flight request (one assignment, one exposure event).
- A
429is retried once after the server'sRetry-After(seconds or HTTP date; default 1 s, capped attimeoutMs).
Errors
getAssignment, evaluateFlag, getAllFlags and fetchAssignments reject with
ExperimentationError (name === 'ExperimentationError'):
code |
Meaning | status |
|---|---|---|
HTTP_ERROR |
Non-2xx response; body holds the parsed JSON error (e.g. {detail}) |
HTTP status (401 bad key, 404 an experiment unknown or not ACTIVE, or an unknown flag key, 422, 429 after the retry) |
NETWORK_ERROR |
fetch rejected (DNS, connection refused, CORS, no fetch available) |
undefined |
TIMEOUT |
The request exceeded timeoutMs |
undefined |
INVALID_RESPONSE |
2xx but the body is not what the SDK expects (or userId missing) |
HTTP status or undefined |
import { ExperimentationClient, ExperimentationError } from '@getexperimently/js-sdk';
try {
const a = await client.getAssignment('checkout_flow', user);
} catch (err) {
if (err instanceof ExperimentationError && err.status === 404) {
// experiment is not ACTIVE — fall back to control
}
}
Backend endpoints used
Every request carries X-API-Key, Content-Type: application/json and Accept: application/json.
apiUrl is the origin only; the SDK appends the paths below.
| SDK call | Method and path | Body / query | 200 response |
|---|---|---|---|
getAssignment, getVariant |
POST /api/v1/tracking/assign |
{"experiment_key","user_id","context"?: object} |
{"experiment_key","user_id","variant_id","variant_name","is_control","configuration"} |
evaluateFlag, isFeatureEnabled |
GET /api/v1/feature-flags/evaluate/{flag_key}?user_id=<id>&context=<url-encoded JSON> |
context = user.attributes (omitted when empty) |
{"key","enabled","config","reason"} |
getAllFlags |
GET /api/v1/feature-flags/user/{user_id}?context=<url-encoded JSON> |
context = attributes (omitted when empty) |
{"<flag_key>": bool, ...} |
track with a key |
POST /api/v1/tracking/track |
{"event_type","event_name","user_id","experiment_key"?,"feature_flag_key"?,"value"?,"metadata"?,"timestamp"?} |
stored event (ignored) |
track without a key, trackBatch |
POST /api/v1/tracking/batch |
{"events":[<track body>...]} (max 100) |
{"success_count","failure_count","errors"} |
fetchAssignments |
GET /api/v1/tracking/assignments/{user_id}?active_only=true |
— | list of assignment rows |
| local mode: ruleset refresh | GET /api/v1/sdk/ruleset (beta) |
If-None-Match: "<version>" |
the ruleset, ETag; 304 when unchanged |
| local mode: evaluation counts | POST /api/v1/tracking/evaluations (beta) |
{"evaluations":[{"flag_key","count","enabled_count","window_start","window_end"}]} |
{"accepted","errors"} |
Errors: 401 bad key; 404 an experiment unknown or not ACTIVE, or an unknown flag key; 422 invalid event; 429
rate limited (Retry-After header, retried once).
TypeScript types
import type {
ClientConfig, UserContext, Assignment, FlagEvaluation,
TrackOptions, TrackEvent, BatchResult, AssignmentRecord,
} from '@getexperimently/js-sdk';
interface Assignment {
experimentKey: string;
userId: string;
variantId: string | null;
variantName: string;
isControl: boolean;
configuration: Record<string, unknown> | null;
}
interface FlagEvaluation {
key: string;
enabled: boolean;
config: unknown | null;
reason?: string; // 'targeting_rule' | 'rollout' | 'inactive' | 'error'; undefined when the server does not send it
source?: 'server' | 'local'; // local mode only: answered in-process, or by the server
}
interface BatchResult {
successCount: number;
failureCount: number;
errors: unknown[]; // server-reported per-event errors plus {message, status?} per failed chunk
}
The raw wire shapes (AssignResponse, FlagEvaluateResponse, BatchResponse, TrackBody) are
exported too.
Hash utilities (compatibility only)
consistentHash(userId, flagKey), md5Hex(input) and md5Bytes(input) are exported so the
cross-SDK golden-vector tests and custom integrations can verify parity:
MD5("{userId}:{flagKey}") → first 4 bytes as little-endian uint32 → ÷ 2^32 (4294967296)
consistentHash('user-123', 'my-flag') ≈ 0.6927449859
Nothing in the SDK calls consistentHash to pick a variant or decide a flag. It is not the
function the server buckets flag rollouts with: that is md5-mod100-v1, the whole MD5 digest of
"{userId}:{flagKey}" read as a big-endian integer, mod 100, which local evaluation reproduces
internally (consistentHash('user-123', 'my-flag') is bucket 69; the server puts that user in
bucket 79).
Local evaluation (server-side, beta)
With evaluation: 'local' the client downloads the flag ruleset (GET /api/v1/sdk/ruleset),
refreshes it every refreshIntervalMs (30 s by default, 5 s minimum), and answers evaluateFlag,
isFeatureEnabled and getAllFlags in-process whenever it can give exactly the server's answer;
otherwise it makes the usual request. Experiments are always assigned by the server. It needs an
Experimently server at 0.11.0 or later and a key with the sdk:ruleset scope, and it is for
server-side code only: the constructor throws in a browser.
const client = new ExperimentationClient({
apiUrl: process.env.EXPERIMENTLY_API_URL!,
apiKey: process.env.EXPERIMENTLY_SERVER_KEY!, // sdk:ruleset scope
evaluation: 'local',
});
await client.ready({ timeoutMs: 5_000 }); // optional; never rejects
const { enabled, reason, source } = await client.evaluateFlag('new_search', user); // source: 'local' | 'server'
await client.close(); // on shutdown: sends the evaluation counts
A change made in the dashboard reaches the process at its next successful refresh. While the API
is unreachable the last ruleset keeps being served (set maxStaleMs to bound that); a 401 or 403
on refresh discards it. What is answered locally, the refresh failure table, safety monitoring and
troubleshooting: Local evaluation.
Browser vs Node.js
The SDK is isomorphic. In the browser the API key is visible to end users, and keys are not scoped
for browser use: every active key, including one shipped to a browser, can evaluate flags and track
events as the user who created it. Use a dedicated key for browser traffic so it can be revoked on its
own, and never give it the sdk:ruleset scope, which lets a key download every flag's targeting
rules. Server-side, lower timeoutMs if the SDK sits on a request path:
const client = new ExperimentationClient({
apiUrl: process.env.EXPERIMENTLY_API_URL!,
apiKey: process.env.EXPERIMENTLY_API_KEY!,
timeoutMs: 1000,
});
Contract smoke
It prints one line:
{"sdk":"js","assign":{"variant_name":"control","is_control":true,"sticky":true},"flag":{"enabled":true},"track":{"ok":true},"fanout":{"ok":true}}
Env: EXPERIMENTLY_API_URL (default http://localhost:8000), EXPERIMENTLY_API_KEY (required),
CONTRACT_EXPERIMENT_KEY (default sdk_contract_ab), CONTRACT_FLAG_KEY (default
sdk_contract_flag), CONTRACT_USER_ID (default random smoke-<uuid>). The script assigns twice
(sticky), evaluates the flag, tracks purchase with a key, tracks page_view without a key
(fan-out) and sends a 2-event trackBatch. npm run smoke is a shortcut.
With a seeded backend (python backend/scripts/seed_sdk_contract.py):
python tests/sdk-contract/live/run_live_contract.py --sdk js --strict. That also runs
examples/local_eval_smoke.mjs with the seed's sdk:ruleset key (the js (local) row): 40
evaluations answered locally, the counts sent on close(), then two error reports sent with the
same key, and the runner checks in the database that no evaluate call was made, that the safety
monitor's denominator grew by exactly 40 and that both reports were counted.
Development
npx jest runs the unit tests, with fetch mocked; npm run build runs tsc into dist/
(CommonJS and .d.ts):