From Optimizely
Optimizely Feature Experimentation (formerly Optimizely Full Stack) and A vs B work the same basic way. Both download a datafile (a small JSON file that lists every live experiment, flag, and rule for your project). Both then evaluate flags in your own code and send events to a collection endpoint.
A feature flag is a setting in your code that you can turn on, off, or change without a new deploy. Optimizely and A vs B both use them, and your event names transfer directly. Flag keys do too when they use only lowercase letters, digits and underscores. A key with a hyphen takes its underscore form: flag-key becomes flag_key. Beyond that, only the SDK calls change.
The migration itself has three parts. Optimizely's OptimizelyDecision object becomes A vs B's Flag<T>. Optimizely's per-variable reads become one JSON flag value with every field on it. And Optimizely's separate decide and isFeatureEnabled calls become a single typed call that always returns the same rich result.
Concept mapping
| Optimizely Feature Experimentation | A vs B |
|---|---|
| Feature flag | Feature flag (same concept; a hyphen in a key becomes an underscore) |
| Feature variable | JSON flag value (define a typed object with all variables) |
| Experiment (A/B test) | Flag with an A/B test rule |
OptimizelyUserContext | EvalContext (SingleContext) |
user.id | context.key |
user.attributes | Top-level attributes on EvalContext |
client.decide('flag-key', user) | server.forUser(ctx).getFlag('flag_key', default) |
OptimizelyDecision.enabled | Flag<T>.isEnabled() |
OptimizelyDecision.variationKey | Flag<T>.variationKey (same field name) |
OptimizelyDecision.variables | Flag<T>.value (JSON object with all variables) |
OptimizelyDecision.reasons | Flag<T>.reasons (same field name) |
client.decideAll(user) | client.getAllFlags() |
client.isFeatureEnabled(key, user) | client.getBoolFlag(key, false).isEnabled() |
client.getFeatureVariable(key, variableKey, user) | client.getJsonFlag<T>(key, {}).value.variableKey |
client.track(eventKey, user, eventTags) | server.track(eventKey, { context, value?, properties? }) |
DecideOption | DecideOption (per-call options: skip recording this evaluation, or include reason strings) |
Client construction
Both SDKs start with an SDK key and download a datafile. Optimizely uses createInstance. A vs B uses its AvsbServer or AvsbClient constructor directly.
Optimizely, Node:
import optimizely from '@optimizely/optimizely-sdk';const optimizelyClient = optimizely.createInstance({ sdkKey: 'your-sdk-key' });await optimizelyClient.onReady();A vs B, Node:
import { AvsbServer } from '@avsbhq/node';const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! });const result = await server.onReady();// result.success / result.degraded / result.sourceOptimizely, Browser:
import optimizely from '@optimizely/optimizely-sdk';const optimizelyClient = optimizely.createInstance({ sdkKey: 'client-key' });await optimizelyClient.onReady();const userCtx = optimizelyClient.createUserContext('u_123', { plan: 'pro' });A vs B, Browser:
import { AvsbClient } from '@avsbhq/browser';const client = new AvsbClient({ sdkKey: 'sdk_production_xxxxxxxxxxxxxxxx', context: { kind: 'user', key: 'u_123', plan: 'pro' },});await client.onReady();Optimizely uses createUserContext to bind one user to the client for a series of calls. A vs B does this too. On the browser SDK it happens when you build the client. On the server SDK, call forUser(ctx) each time instead.
Identity model
Optimizely builds a fresh user context on every request, with a user id and attributes. A vs B's server client keeps no state between calls: pass a context to forUser each time. The browser client keeps one context and gives you clear methods to update it.
Optimizely:
// Each request creates a new contextconst userCtx = optimizelyClient.createUserContext('u_123', { plan: 'pro', country: 'US' });const decision = userCtx.decide('checkout_redesign');// No partial update: re-create with new attributesA vs B:
// Server: stateless, context per callconst ctx = { kind: 'user', key: 'u_123', plan: 'pro', country: 'US' };const flag = server.forUser(ctx).getBoolFlag('checkout_redesign', false);// Browser: stateful context with mutation methodsclient.identify({ kind: 'user', key: 'u_123', plan: 'pro' });client.updateAttributes({ country: 'US' }); // patch without full replacement// Stitch the anonymous identity to the signed-in one. Synchronous.client.alias({ kind: 'user', key: 'anon_xyz' }, { kind: 'user', key: 'u_123' });Flag evaluation
Optimizely's decide returns an OptimizelyDecision with enabled, variationKey, and variables. A vs B's Flag<T> carries the same information, under slightly different field names.
Optimizely:
const userCtx = optimizelyClient.createUserContext('u_123', { plan: 'pro' });// Boolean feature flagconst decision = userCtx.decide('show_new_checkout');if (decision.enabled) { // show new checkout}// Feature with variables (JSON)const configDecision = userCtx.decide('pricing_config');const price = configDecision.variables['base_price'] as number;// Individual variable fetchconst price2 = optimizelyClient.getFeatureVariableDouble( 'pricing_config', 'base_price', 'u_123', { plan: 'pro' },);// Decide all flagsconst allDecisions = userCtx.decideAll();A vs B:
const uc = server.forUser({ kind: 'user', key: 'u_123', plan: 'pro' });// Boolean flagconst flag = uc.getBoolFlag('show_new_checkout', false);if (flag.isEnabled()) { // show new checkout}// flag.variationKey → e.g. 'treatment', 'control'// flag.source → 'rule' | 'default' | 'holdout' | ...// flag.reasons → string[]// JSON flag with typed variablesinterface PricingConfig { basePrice: number; currency: string }const config = uc.getJsonFlag<PricingConfig>('pricing_config', { basePrice: 99, currency: 'USD' });const price = config.value.basePrice;// All flags (no exposures fired by default)const allFlags = uc.getAllFlags();Optimizely reads each feature variable with its own method (getFeatureVariableDouble, getFeatureVariableString, and so on). In A vs B, write one TypeScript type for all of a flag's variables, then read them together with getJsonFlag<T>. That is safer, and it is one call instead of several.
Tracking events
Optimizely's track takes the event key, the user id, the user's attributes, and a tags map that carries the revenue field. A vs B uses one payload object instead.
Optimizely:
optimizelyClient.track('purchase_completed', 'u_123', { plan: 'pro' }, { revenue: 4999, // in cents in Optimizely value: 49.99, tags: { orderId: 'ord_99' },});A vs B:
server.track('purchase_completed', { context: { kind: 'user', key: 'u_123', plan: 'pro' }, revenue: 49.99, // major units, not cents});Multi-context
Optimizely Feature Experimentation calls this a qualified audience (a named, reusable group of visitors that match certain conditions). A vs B uses the same kind: 'multi' shape as LaunchDarkly. If you target on organization, device, or another entity type, add each one as a context kind in the A vs B dashboard.
A vs B, multi-context:
import type { MultiContext } from '@avsbhq/core';const ctx: MultiContext = { kind: 'multi', user: { kind: 'user', key: 'u_123', plan: 'pro' }, organization: { kind: 'organization', key: 'org_42', tier: 'enterprise' },};server.forUser(ctx).getBoolFlag('enterprise_sso', false);Streaming updates
Optimizely refreshes the datafile on a timer you can set. A vs B works the same way, with an optional live-push mode (SSE) on the browser.
A vs B, flag change listener:
client.on('configUpdate', ({ publishedAt, reason }) => { // Datafile refreshed; re-render flag-dependent UI if needed});client.on('flagChange', ({ flagKey, previousValue, newValue }) => { // A specific flag value changed});Bootstrap / SSR
Optimizely's datafileManager caches the datafile on your server. A vs B's fetchDatafile does the same job: fetch it on the server, then pass it as bootstrap to the client provider.
Optimizely, SSR datafile pre-fetch:
import optimizely from '@optimizely/optimizely-sdk';const datafileUrl = 'https://cdn.optimizely.com/datafiles/your-key.json';const resp = await fetch(datafileUrl);const datafile = await resp.json();const optimizelySsrClient = optimizely.createInstance({ datafile });A vs B, SSR bootstrap:
import { AvsbProvider } from '@avsbhq/react';import { fetchDatafile } from '@avsbhq/browser/server';const datafile = await fetchDatafile(process.env.AVSB_SDK_KEY!);// Pass to client provider<AvsbProvider sdkKey="..." context={ctx} bootstrap={datafile ?? undefined}> <YourApp /></AvsbProvider>Sticky bucketing
Sticky bucketing is putting a visitor into a group with a consistent hash of their id, so they land in the same group every time. Optimizely does this with a user profile service. A vs B uses a StickyBucketService with the same lookup and save methods, but a richer StickyAssignment payload that includes the rule that made the decision.
Optimizely, user profile service:
const userProfileService = { lookup: (userId: string) => ({ user_id: userId, experiment_bucket_map: {} }), save: (userProfile: object) => { /* persist */ },};const optimizelyStickyClient = optimizely.createInstance({ sdkKey: 'your-sdk-key', userProfileService,});A vs B, sticky bucket service (@avsbhq/node also ships InMemoryStickyBucketService and RedisStickyBucketService):
import type { StickyBucketService, StickyAssignment } from '@avsbhq/core';class MyStore implements StickyBucketService { // Both methods are synchronous, so back them with a warm local cache. private cache = new Map<string, StickyAssignment>(); lookup(userId: string, flagKey: string): StickyAssignment | null { return this.cache.get(`${userId}:${flagKey}`) ?? null; } save(userId: string, flagKey: string, assignment: StickyAssignment): void { this.cache.set(`${userId}:${flagKey}`, assignment); }}const stickyServer = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY!, stickyBucketService: new MyStore(),});Holdouts
A holdout is a slice of visitors kept out of every experiment, so you can measure the combined effect of everything else you shipped. Optimizely Feature Experimentation has no built-in holdout. Teams usually build one by hand, by excluding a segment from every experiment.
A vs B includes holdouts on every plan. Set one up in the dashboard and attach flags to it. Held-out visitors get source: 'holdout'.
Cleanup
Optimizely:
optimizelyClient.close();A vs B:
await server.close(); // async flush + stop pollingTesting
Optimizely, test instance:
import optimizely from '@optimizely/optimizely-sdk';const optimizelyTestClient = optimizely.createInstance({ datafile: { /* inline test datafile */ }, logLevel: 'error',});A vs B, mock client:
import { createMockClient } from '@avsbhq/test';const mock = createMockClient({ flags: { show_new_checkout: true, pricing_config: { basePrice: 49, currency: 'USD' }, },});Cutover checklist
Remove Optimizely packages
Uninstall @optimizely/optimizely-sdk and any related Optimizely packages from your project.
Install A vs B packages
Install @avsbhq/node, @avsbhq/browser, and @avsbhq/react as needed.
Migrate user context construction
Replace client.createUserContext(id, attributes) with an inline EvalContext object: { kind: 'user', key: id, ...attributes }.
Replace decide calls
Replace each userCtx.decide(key) with the matching typed call: getBoolFlag, getStringFlag, or getJsonFlag<T>.
Consolidate feature variables
Group all of a flag's feature variables into one TypeScript interface and use getJsonFlag<T>. Remove the per-type variable calls (getFeatureVariableDouble, and so on).
Replace isFeatureEnabled calls
Replace client.isFeatureEnabled(key, userId, attrs) with server.forUser(ctx).getBoolFlag(key, false).isEnabled().
Replace track calls
Replace client.track(event, userId, attrs, tags) with server.track(event, { context, value, properties }).
Migrate user profile service (if used)
Implement the A vs B StickyBucketService interface with lookup and save returning StickyAssignment objects.
Update tests
Replace inline test datafiles with createMockClient from @avsbhq/test.
Verify and deploy
Run npm run build and npx tsc --noEmit. Check flags and events in the A vs B dashboard before you go live.