Migrate from LaunchDarkly to A vs B
LaunchDarkly and A vs B share a very similar mental model: both use a typed context object to identify the evaluation subject, both return a rich evaluation envelope, and both support multi-context targeting. The main practical differences are package names, a cleaner typed-evaluator API that eliminates the need for separate variation andvariationDetail calls, and first-class holdout and bandit support built into the core SDK rather than as enterprise add-ons. Your targeting rules, audience definitions, and event keys transfer directly — only the SDK call sites change.
Concept mapping
| LaunchDarkly | A vs B |
|---|---|
LDContext / LDUser | EvalContext (SingleContext or MultiContext) |
context.kind | context.kind (identical field name) |
context.key | context.key (identical field name) |
LDMultiKindContext | MultiContext with kind: 'multi' |
client.variation(key, ctx, default) | client.getFlag(key, default).value |
client.variationDetail(key, ctx, default) | client.getFlag(key, default) — always returns the full Flag<T> |
| Flag variation value | Flag<T>.value |
LDEvaluationDetail.variationIndex | Flag<T>.variationKey (string key, not numeric index) |
LDEvaluationDetail.reason | Flag<T>.reasons (string array) + .source (enum) |
client.identify(ctx) | client.identify(ctx) — replaces the full context |
client.track(eventName, ctx, data, metricValue) | client.track(eventKey, { value?, properties? }) |
| LaunchDarkly metric | A vs B metric event key (same concept, different config UI) |
| Holdout (enterprise) | FlagDatafileHoldout — included in all plans |
| Big segments | BigSegmentStore interface (bring your own backing store) |
client.close() | client.close() (async, returns Promise<void>) |
Client construction
Both SDKs are initialized with an SDK key and return an async-ready client. The key difference is that A vs B's onReady()resolves (never rejects) with a typed InitResult so you can handle degraded-but-functional starts explicitly.
1import * as ld from 'launchdarkly-node-server-sdk';2
3const client = ld.init('sdk-your-server-key');4await client.waitForInitialization();5// client is ready1import { AvsbServer } from '@avsbhq/node';2
3const server = new AvsbServer({ sdkKey: 'sdk_production_abc123' });4const result = await server.onReady();5// result.success === true → fully initialized6// result.degraded === true → running on defaults; polling continues1import * as LDClient from 'launchdarkly-js-client-sdk';2
3const ctx = { kind: 'user', key: 'u_123', plan: 'pro' };4const client = LDClient.initialize('client-side-id', ctx);5await client.waitForInitialization();1import { AvsbClient } from '@avsbhq/browser';2
3const client = new AvsbClient({4 sdkKey: 'sdk_production_abc123',5 context: { kind: 'user', key: 'u_123', plan: 'pro' },6});7const result = await client.onReady();Identity model
LaunchDarkly's browser identify(ctx) replaces the current user and re-fetches flags. A vs B follows the same replace semantics but splits the operation into three explicit methods so the intent is always clear in your code.
1// Replace the whole context (triggers flag re-evaluation)2await client.identify({ kind: 'user', key: 'u_456', plan: 'enterprise' });3
4// Partial attribute update — no direct equivalent; must re-identify with full ctx1// Replace the full context (equivalent to LD identify)2client.identify({ kind: 'user', key: 'u_456', plan: 'enterprise' });3
4// Patch only specific attributes on an existing context — no full replacement5client.updateAttributes({ plan: 'enterprise' });6// Optionally target a specific context kind in a multi-context:7client.updateAttributes({ tier: 'gold' }, 'organization');8
9// Link an anonymous pre-login identity to a signed-up user10await client.alias(11 { kind: 'user', key: 'anon_abc' }, // previousContext12 { kind: 'user', key: 'u_456' } // newContext (now identified)13);updateAttributes for post-login attribute enrichment (adding a plan or orgId after authentication) rather than calling identify again with the full context. Use alias once per session to stitch the anonymous and authenticated identities together in analytics.Flag evaluation
LaunchDarkly provides two calls: variation (returns raw value) and variationDetail (returns value + reason). A vs B always returns the full Flag<T>envelope from every call — there is no separate “detail” variant. Typed-evaluator helpers give you compile-time safety without a cast.
1// Raw value only2const showBanner = client.variation('show_banner', ctx, false);3
4// With reason (separate call)5const detail = client.variationDetail('show_banner', ctx, false);6// detail.value, detail.reason.kind, detail.variationIndex1// Every call returns Flag<T> — no separate 'detail' variant needed2const flag = server.forUser(ctx).getBoolFlag('show_banner', false);3flag.value // boolean4flag.isEnabled() // source === 'rule' && value is truthy5flag.variationKey // string key (e.g. 'on') or null if default served6flag.source // 'rule' | 'default' | 'holdout' | 'sticky' | 'not_found' | ...7flag.reasons // string[] — human-readable reasons array8flag.ruleId // matched rule id or null9
10// Typed evaluator variants11const theme = server.forUser(ctx).getStringFlag('homepage_theme', 'default');12const limit = server.forUser(ctx).getNumberFlag('rate_limit', 100);13const config = server.forUser(ctx).getJsonFlag<PricingConfig>('pricing_config', {});14
15// Browser client (context already bound at construction / identify)16const flag2 = client.getBoolFlag('show_banner', false);variationIndexis a numeric array position. A vs B's variationKey is the string key you assigned in the flag builder (e.g. 'control', 'treatment_a'). String keys survive flag variation reorders without breaking stored references.Tracking events
LaunchDarkly's track takes four positional arguments including the context. A vs B binds context at client construction (or via forUser on the server), so the call site is simpler and the metric value is a named field rather than a positional argument.
1client.track('purchase_completed', ctx, { orderId: 'ord_99' }, 49.99);1// Browser — context already bound2client.track('purchase_completed', { value: 49.99, properties: { orderId: 'ord_99' } });3
4// Server — pass context in payload5server.track('purchase_completed', {6 context: { kind: 'user', key: 'u_123' },7 value: 49.99,8 properties: { orderId: 'ord_99' },9});10
11// Server with forUser scope12server.forUser({ kind: 'user', key: 'u_123' }).track('purchase_completed', {13 value: 49.99,14 properties: { orderId: 'ord_99' },15});Multi-context
Both SDKs support multi-context targeting. The shape is nearly identical — A vs B uses the same kind: 'multi'discriminant as LaunchDarkly. The main difference is that A vs B's hashAttribute on each rule uses a dotted-path syntax to address the bucketing key across contexts.
1const multiCtx: LDMultiKindContext = {2 kind: 'multi',3 user: { kind: 'user', key: 'u_123', plan: 'pro' },4 organization: { kind: 'organization', key: 'org_42', tier: 'enterprise' },5};6client.variation('enterprise_feature', multiCtx, false);1import type { MultiContext } from '@avsbhq/core';2
3const multiCtx: MultiContext = {4 kind: 'multi',5 user: { kind: 'user', key: 'u_123', plan: 'pro' },6 organization: { kind: 'organization', key: 'org_42', tier: 'enterprise' },7};8// In the dashboard, set hashAttribute to 'organization.key' to bucket by org9server.forUser(multiCtx).getBoolFlag('enterprise_feature', false);Streaming updates
LaunchDarkly uses a persistent SSE streaming connection per client. A vs B uses a hybrid: short-poll by default (configurable interval), with an opt-in browser SSE mode for real-time push. Flag-change notifications use the unified event bus in both modes.
1client.on('change', (changes) => {2 // changes is a map of flagKey → { current, previous }3});1// Subscribe to individual flag changes2const unsub = client.on('flagChange', ({ flagKey, previousValue, newValue }) => {3 console.log(flagKey, previousValue, '→', newValue);4});5// Call unsub() to stop listening6
7// Subscribe to any datafile update (e.g. to force a re-render)8client.on('configUpdate', ({ publishedAt, reason }) => {9 // reason: 'poll' | 'stream' | 'manual'10});Bootstrap / SSR
Both SDKs support a bootstrap payload to avoid a flash of default content on server-rendered pages. A vs B's bootstrap is the full FlagDatafile fetched on the server and passed to the provider.
1// Server component2import { init } from '@launchdarkly/node-server-sdk';3const ldClient = init(process.env.LD_SDK_KEY!);4await ldClient.waitForInitialization();5const bootstrapData = ldClient.allFlagsState(ctx);6// Pass serialized state to client1// Server component (app/layout.tsx)2import { fetchDatafile } from '@avsbhq/node/server';3
4const datafile = await fetchDatafile(process.env.AVSB_SDK_KEY!);5// Pass datafile to the client-side AvsbProvider6
7// Client provider (app/providers.tsx)8'use client'9import { AvsbProvider } from '@avsbhq/react';10import type { FlagDatafile } from '@avsbhq/react';11
12export function Providers({ datafile, children }: {13 datafile: FlagDatafile | null;14 children: React.ReactNode;15}) {16 return (17 <AvsbProvider18 sdkKey={process.env.NEXT_PUBLIC_AVSB_SDK_KEY!}19 context={{ kind: 'user', key: 'anon' }}20 bootstrap={datafile ?? undefined}21 >22 {children}23 </AvsbProvider>24 );25}Sticky bucketing
LaunchDarkly's sticky bucketing stores a simple variation index. A vs B's StickyBucketService stores a richer StickyAssignment that includes the rule that produced the assignment and the time it was made — enabling age-based reassignment policies.
1// Provide a StickyBucketingService implementation at init2const client = ld.init(sdkKey, {3 stickyBucketService: new RedisStickyBucketService(redisClient),4});1import { StickyBucketService, StickyAssignment } from '@avsbhq/core';2
3class RedisStickyService implements StickyBucketService {4 lookup(userId: string, flagKey: string): StickyAssignment | null {5 // return { variationId, ruleId, ruleType, assignedAt } or null6 }7 save(userId: string, flagKey: string, assignment: StickyAssignment): void {8 // persist { variationId, ruleId, ruleType, assignedAt }9 }10}11
12const server = new AvsbServer({13 sdkKey: process.env.AVSB_SDK_KEY!,14 stickyBucketService: new RedisStickyService(),15});Holdouts
LaunchDarkly offers holdouts as an enterprise feature. In A vs B, holdouts are included in all plans and are configured in the dashboard under Holdouts. Users in the holdout cohort receive source: 'holdout' and the configured default variation for each flag — no SDK code change is needed. Exposure events fire with ruleType: 'holdout' so you can exclude held-out cohorts from A/B analysis.
1const flag = server.forUser(ctx).getBoolFlag('checkout_redesign', false);2if (flag.source === 'holdout') {3 // This user is in the holdout group — they see the control across all flags4 // in this holdout. Do not count them in experiment metrics.5}Cleanup
1await client.close();1// Flush any buffered events and stop polling/streaming2await server.close(); // server SDK3await client.close(); // browser SDK — also calls flush() internallyTesting
LaunchDarkly provides test fixture helpers per SDK. A vs B ships a dedicated @avsbhq/test package with a createMockClient that implements the full client interface with controllable flag values.
1import { TestData } from 'launchdarkly-node-server-sdk';2
3const td = TestData.dataSource();4td.update(td.flag('show_banner').booleanFlag().variationForAll(true));5const client = ld.init('sdk-key', { dataSource: td });1import { createMockClient } from '@avsbhq/test';2
3const mockClient = createMockClient({4 flags: {5 show_banner: true,6 homepage_theme: 'blue',7 rate_limit: 100,8 },9});10// mockClient conforms to the full AvsbClient interface11// Inject it wherever your code expects an AvsbClientCutover checklist
Uninstall LaunchDarkly packages
launchdarkly-node-server-sdk, launchdarkly-js-client-sdk, and launchdarkly-react-client-sdk from your package.json.Install A vs B packages
npm install @avsbhq/node for server code, npm install @avsbhq/browser for browser code, and npm install @avsbhq/react for React apps.Swap SDK key
Migrate context construction
LDContext / LDUser objects with A vs B EvalContext. Field names (kind, key, attributes) are identical — the type import changes.Replace variation calls
client.variation(key, ctx, default) with client.getBoolFlag / getStringFlag / getNumberFlag / getJsonFlag as appropriate. Drop the ctx argument on the browser client (it uses the bound context). Use forUser(ctx) on the server.Replace variationDetail calls
variationDetail call becomes a plain getFlag call — the full Flag<T> envelope is always returned.Migrate track calls
client.track(event, ctx, data, metricValue) with client.track(event, { value, properties }). The ctx argument is no longer passed on the browser client.Migrate identify calls
client.identify(ctx) works the same way. Add client.alias(anonCtx, identifiedCtx) at login time to stitch anonymous and authenticated identities.Update sticky bucketing (if used)
StickyBucketingServiceimplementation with one that implements A vs B's StickyBucketService interface — lookup and save now work with StickyAssignment objects instead of plain variation index strings.Update tests
createMockClient from @avsbhq/test.Verify and deploy
npm run build and npx tsc --noEmit. Deploy to a staging environment and confirm flag evaluations and metric events are reaching the A vs B dashboard before cutting over production.