Web SDK reference
Browser SDK API — every option, every hook, every escape hatch, with examples.
Package: @fabricorg/experiments-web
The web SDK is the browser delivery layer. It fetches manifests, assigns
users deterministically, persists sticky decisions, applies variants
(js / css / domOps), gates on triggers, and emits exposure events.
Current bundle: 4.37 KB gz core, 4.42 KB gz for the tag-loader IIFE. CI gate at 6 KB.
pnpm add @fabricorg/experiments-webQuickstart
import { init } from '@fabricorg/experiments-web';
const client = await init({
manifestUrl: 'https://manifest.example.com/v1/acme/manifest.json',
subjectId: currentUser.id, // any stable string per subject
onExposure: (record) => analytics.track('experiment_exposure', record),
});
const variant = client.treatment('homepage-cta');
if (variant === 'treatment') renderNewCta();
const flagPayload = client.payload<{ debounceMs: number }>('search-debounce');
const expConfig = client.metadata<{ flushIntervalMs: number }>('search-debounce');For SSR or pre-fetched manifests, skip the init() round-trip:
import { createClient, type Manifest } from '@fabricorg/experiments-web';
const manifest: Manifest = await myServer.getManifest();
const client = createClient(manifest, { manifestUrl: '', subjectId });InitOptions
Required
| Field | Type | Notes |
|---|---|---|
manifestUrl | string | URL to the signed manifest JSON |
subjectId | string | Stable subject id used for bucketing |
Tracking & telemetry
| Field | Type | Notes |
|---|---|---|
onExposure | (record) => void | Fires once per (experiment, variant) on assignment |
onRecipeFailure | (record) => void | Fires only when variant code throws — additive to onExposure |
onTrack | (name, props) => void | Custom event sink for client.track() |
beaconEndpoint | string | URL for sendBeacon-batched exposure POSTs on pagehide |
maxErrorStackLength | number | Cap on failure.stack chars sent to handlers. Default 1000 |
Sticky bucketing
| Field | Type | Notes |
|---|---|---|
storage | StickyStorageAdapter | Plug-in: cookieStorage() (default), localStorageOnly(), sessionStorageOnly(), inMemoryStorage(), or your own |
cookiePrefix | string | Storage key prefix. Default 'fx.' |
decisionAdapter | (ctx) => string | null | Override the bucketer; return null to fall through |
Eligibility
| Field | Type | Notes |
|---|---|---|
excluded | bool | () => bool | Truthy → all treatment()/payload() return null. Default checks navigator.cookieEnabled === false |
triggers | Record<expId, () => bool> | Per-experiment activation override (the Mojito trigger.js escape hatch) |
targetingContext | object | Evaluated against each experiment's audience.rule. Typically { user: { country, tier, ... } }. See YAML targeting. |
Preview tokens
| Field | Type | Notes |
|---|---|---|
previewJwksUrl | string | Public JWKS URL used to verify asymmetrically signed ?fxpreview=&fxtoken= params. Derived from manifestUrl by default. |
previewJwks | object | Inline public JWKS, useful for tests or SSR-hydrated apps. |
previewSecret | string | Deprecated legacy HS256 verifier; do not embed in production HTML. |
locationSearch | string | Override window.location.search (tests) |
Misc
| Field | Type | Notes |
|---|---|---|
fetchImpl | typeof fetch | Override the global fetch (SSR / Node) |
Client API
interface ClientApi {
treatment(experimentId: string): string | null;
payload<T>(experimentId: string): T | undefined;
metadata<T>(experimentId: string): T | undefined;
expose(experimentId: string): void;
track(eventName: string, props?: Record<string, unknown>): void;
}treatment(id)— assigned variant key, ornullif ineligible / unknown.payload(id)—variant.payloadfor the assigned variant.metadata(id)— per-experiment metadata bag (independent of variant).expose(id)— emits the deferred exposure for experiments authored withmanualExposure: true. No-op otherwise.track(name, props)— generic event sink, routed toonTrack.
Examples
Custom storage adapter
Use localStorage only (privacy-first), sessionStorage (per-tab), or
in-memory (tests):
import { init, localStorageOnly, inMemoryStorage } from '@fabricorg/experiments-web';
await init({
manifestUrl,
subjectId,
storage: localStorageOnly({ cookiePrefix: 'acme.fx.' }),
});Implement your own — for example, sticky decisions mirrored to your backend so server-rendered HTML can pre-resolve the variant:
import type { StickyStorageAdapter } from '@fabricorg/experiments-web';
const myStorage: StickyStorageAdapter = {
read: (id) => sessionStorage.getItem(`acme.${id}`) ?? null,
write: (id, key) => {
sessionStorage.setItem(`acme.${id}`, key);
fetch('/api/sticky', {
method: 'POST',
body: JSON.stringify({ id, key }),
keepalive: true,
});
},
};Decision adapter
await init({
manifestUrl,
subjectId,
decisionAdapter: ({ experimentId, variants }) => {
// Force everyone in QA to control.
if (currentUser.role === 'qa') return 'control';
// Otherwise fall through to murmur3 bucketing.
return null;
},
});Precedence (highest to lowest):
- Signed
?fxpreview=URL token - Manifest
divertTo - Sticky storage (subject was already assigned)
- Test-level
sampleRateexclusion decisionAdapter(skipped if returns null / unknown key / throws)- Built-in murmur3 bucketing
- Per-variant
recipeSampleRategate (applied after the chosen variant)
Excluding bots / consent gating
await init({
manifestUrl,
subjectId,
excluded: () => isBot(navigator.userAgent) || !consent.has('analytics'),
});When the predicate is truthy, every treatment() and payload() returns
null/undefined and no exposures are emitted — useful for GDPR
opt-out and headless-browser exclusion.
Variant failure alarming
onRecipeFailure fires alongside onExposure when a variant's js or
a domOps op throws. Wire it to your error tracker:
import { init, getOrCreateAnonymousId } from '@fabricorg/experiments-web';
import { dataLayerAdapter } from '@fabricorg/experiments-web-adapters';
await init({
manifestUrl,
subjectId: getOrCreateAnonymousId(),
onExposure: dataLayerAdapter(),
onRecipeFailure: (record) => {
Sentry.captureMessage(
`variant ${record.experimentId}/${record.variantKey} crashed`,
{ extra: record.failure ?? undefined },
);
},
maxErrorStackLength: 2000,
});Manual exposure timing
Sometimes you want to count exposure only when the variant actually mattered — e.g., after the user scrolled to the modified section:
# experiments/scroll-test.yaml
id: scroll-test
name: Scroll-gated exposure
manualExposure: true
variants: [...]const variant = client.treatment('scroll-test');
window.addEventListener('scroll', () => {
if (window.scrollY > 800) client.expose('scroll-test');
}, { once: true });Anonymous subject IDs
For static sites with no logged-in user:
import { init, getOrCreateAnonymousId } from '@fabricorg/experiments-web';
await init({
manifestUrl,
subjectId: getOrCreateAnonymousId({ cookieName: '_acme_sid', ttlDays: 730 }),
});Activation triggers
Triggers gate when the SDK applies a variant. Most expressive cases live in the YAML reference. For programmatic overrides:
await init({
manifestUrl,
subjectId,
triggers: {
'cart-flow': () => location.pathname.startsWith('/cart'),
},
});Exposure record shape
interface ExposureRecord {
experimentId: string;
subjectId: string;
variantKey: string;
manifestVersion: number;
at: string; // ISO 8601
failure: { message: string; stack?: string } | null;
preview?: boolean; // set when assignment came from a signed preview token
}preview: true records are never sent to onExposure / onRecipeFailure / beacon —
preview assignments must not pollute analyses.
Tag-loader (no app code)
For marketing sites and GTM, see the tag-loader guide.
Validating
pnpm --filter @fabricorg/experiments-web test # node tests
pnpm --filter @fabricorg/experiments-web test:browser # real-browser harness
pnpm --filter @fabricorg/experiments-web size # bundle size gate