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: 5.82 KB gz core and 6.15 KB gz for the tag-loader IIFE. CI caps them at 8.2 KB and 8.5 KB respectively, including fail-closed Ed25519 manifest verification; the QA inspector is an on-demand asset. The additional tag-loader telemetry distinguishes trigger rejection, exceptions, and selector timeouts.
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');init() rejects unsigned, malformed, tampered, stale (when a maximum age is
configured), or retired-key manifests before assignment. It fetches public
keys from /.well-known/jwks/{tenantId} on the manifest origin unless you
provide a JWKS URL or inline keys. Verification is enabled by default.
For SSR or pre-fetched manifests, skip the init() round-trip only after your
server has verified the delivery. createClient() is a trusted direct-injection
boundary and does not repeat network-delivery verification:
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 |
Manifest trust
| Field | Type | Notes |
|---|---|---|
requireSignedManifest | boolean | Defaults to true for init(). Set false only for isolated tests with a trusted in-memory fixture. |
manifestJwksUrl | string | Public manifest-verification JWKS URL; derived from manifestUrl by default. |
manifestJwks | object | Inline manifest-verification JWKS for tests or preloaded keys. |
manifestRevocationsUrl | string | Revoked-version record URL; derived from manifestUrl by default. |
manifestRevocations | { versions: number[] } | Inline revocation record for tests or preloaded trust metadata. |
maxManifestAgeMs | number | Optional replay window measured from the signed builtAt timestamp. |
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 |
onActivationFailure | (record) => void | Trigger rejection, error, or selector timeout; typed Mojito onVeilTimeout equivalent |
onTrack | (name, props) => void | Custom event sink for client.track() |
beaconEndpoint | string | URL for sendBeacon-batched exposure POSTs on pagehide |
conversionEndpoint | string | Conversion URL; defaults by replacing /exposure with /conversion |
beaconFlushIntervalMs | number | Optional periodic flush; pagehide flushing is always enabled |
maxErrorStackLength | number | Cap on failure.stack chars sent to handlers. Default 1000 |
analyticsDimensions | Record<string, string> | Explicit low-cardinality dimensions copied to exposure events for segmented Studio results. Targeting context is not copied automatically. |
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. |
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;
activateAll(): Array<{ experimentId: string; variantKey: string }>;
payload<T>(experimentId: string): T | undefined;
metadata<T>(experimentId: string): T | undefined;
expose(experimentId: string): void;
track(eventName: string, props?: Record<string, unknown>): void;
debugSnapshot(): DebugSnapshot;
}treatment(id)— assigned variant key, ornullif ineligible / unknown.activateAll()— activates every eligible running experiment and returns assignments.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 toonTrackand, when beacon telemetry is configured, queued as a conversion. A numericprops.valueis preserved as the conversion value.debugSnapshot()— read-only manifest, state, eligibility, and assignment data used by the on-demand browser inspector.
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 - Sticky storage (subject was already assigned)
- Test-level
sampleRateand targeting eligibility - Manifest
divertTo decisionAdapter(skipped if it returns null, an unknown key, or 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,
});Activation failure and selector timeout
await init({
manifestUrl,
subjectId,
onActivationFailure: ({ experimentId, variantKey, reason, triggerKind, message }) => {
analytics.track('experiment_activation_failure', {
experimentId,
variantKey,
reason,
triggerKind,
message,
})
},
})reason is trigger-rejected, trigger-error, or trigger-timeout. Host
trigger exceptions are contained and reported; a missing selector after its
deadline reports trigger-timeout without applying the variant.
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 }),
});The cookie is written with SameSite=Lax and, on HTTPS, the Secure flag.
Pass secure: false to force it off (e.g. HTTP localhost testing), or
secure: true to force it on.
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
tenantId: string;
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