FabricFabricExperiments
Reference

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-web

Quickstart

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

FieldTypeNotes
manifestUrlstringURL to the signed manifest JSON
subjectIdstringStable subject id used for bucketing

Tracking & telemetry

FieldTypeNotes
onExposure(record) => voidFires once per (experiment, variant) on assignment
onRecipeFailure(record) => voidFires only when variant code throws — additive to onExposure
onTrack(name, props) => voidCustom event sink for client.track()
beaconEndpointstringURL for sendBeacon-batched exposure POSTs on pagehide
maxErrorStackLengthnumberCap on failure.stack chars sent to handlers. Default 1000

Sticky bucketing

FieldTypeNotes
storageStickyStorageAdapterPlug-in: cookieStorage() (default), localStorageOnly(), sessionStorageOnly(), inMemoryStorage(), or your own
cookiePrefixstringStorage key prefix. Default 'fx.'
decisionAdapter(ctx) => string | nullOverride the bucketer; return null to fall through

Eligibility

FieldTypeNotes
excludedbool | () => boolTruthy → all treatment()/payload() return null. Default checks navigator.cookieEnabled === false
triggersRecord<expId, () => bool>Per-experiment activation override (the Mojito trigger.js escape hatch)
targetingContextobjectEvaluated against each experiment's audience.rule. Typically { user: { country, tier, ... } }. See YAML targeting.

Preview tokens

FieldTypeNotes
previewJwksUrlstringPublic JWKS URL used to verify asymmetrically signed ?fxpreview=&fxtoken= params. Derived from manifestUrl by default.
previewJwksobjectInline public JWKS, useful for tests or SSR-hydrated apps.
previewSecretstringDeprecated legacy HS256 verifier; do not embed in production HTML.
locationSearchstringOverride window.location.search (tests)

Misc

FieldTypeNotes
fetchImpltypeof fetchOverride 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, or null if ineligible / unknown.
  • payload(id)variant.payload for the assigned variant.
  • metadata(id) — per-experiment metadata bag (independent of variant).
  • expose(id) — emits the deferred exposure for experiments authored with manualExposure: true. No-op otherwise.
  • track(name, props) — generic event sink, routed to onTrack.

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):

  1. Signed ?fxpreview= URL token
  2. Manifest divertTo
  3. Sticky storage (subject was already assigned)
  4. Test-level sampleRate exclusion
  5. decisionAdapter (skipped if returns null / unknown key / throws)
  6. Built-in murmur3 bucketing
  7. Per-variant recipeSampleRate gate (applied after the chosen variant)
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

On this page