From LaunchDarkly
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 and variationDetail 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, { revenue?, value? }) |
| LaunchDarkly metric | A vs B metric event key (same concept, different config UI) |
| Holdout (enterprise) | FlagDatafileHoldout (included in all plans) |
| Big segments | No equivalent. Audience segments ship inside the datafile and evaluate offline, with no external membership 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.
LaunchDarkly, Node server:
import * as ld from 'launchdarkly-node-server-sdk';const ldServer = ld.init('sdk-your-server-key');await ldServer.waitForInitialization();// ldServer is readyA vs B, Node server:
import { AvsbServer } from '@avsbhq/node';const server = new AvsbServer({ sdkKey: 'sdk_production_xxxxxxxxxxxxxxxx' });const result = await server.onReady();// result.success === true → ready to answer (fresh, or degraded below)// result.degraded === true → serving a cached datafile; a refresh failed, so retry keeps pollingLaunchDarkly, Browser:
import * as LDClient from 'launchdarkly-js-client-sdk';const ctx = { kind: 'user', key: 'u_123', plan: 'pro' };const ldClient = LDClient.initialize('client-side-id', ctx);await ldClient.waitForInitialization();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' } });const result = await client.onReady();LaunchDarkly uses separate server-side SDK keys and client-side IDs. A vs B uses a single per-environment SDK key for both runtimes. Find yours on the Environments page in your feature flag project.
- Environments lives in the sidebar on its own, not inside Settings.
- Click Reveal to see the full key, then Copy to copy it.
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.
LaunchDarkly:
// Replace the whole context (triggers flag re-evaluation)await ldClient.identify({ kind: 'user', key: 'u_456', plan: 'enterprise' });// Partial attribute update: no direct equivalent; must re-identify with full ctxA vs B:
// Replace the full context (equivalent to LD identify)client.identify({ kind: 'user', key: 'u_456', plan: 'enterprise' });// Patch only specific attributes on an existing context: no full replacementclient.updateAttributes({ plan: 'enterprise' });// Optionally target a specific context kind in a multi-context:client.updateAttributes({ tier: 'gold' }, 'organization');// Link an anonymous pre-login identity to a signed-up user. Synchronous:// it records the moment, it does not move past decisions.client.alias({ kind: 'user', key: 'anon_abc' }, { kind: 'user', key: 'u_456' }); // previousContext, newContextUse 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.
LaunchDarkly:
// Raw value onlyconst showBanner = ldClient.variation('show_banner', ctx, false);// With reason (separate call)const detail = ldClient.variationDetail('show_banner', ctx, false);// detail.value, detail.reason.kind, detail.variationIndexA vs B:
interface PricingConfig { currency: string; seats: number; }// Every call returns Flag<T>: no separate 'detail' variant neededconst flag = server.forUser(ctx).getBoolFlag('show_banner', false);flag.value // booleanflag.isEnabled() // true only for a real decision (rule, holdout, bandit, sticky, override)flag.variationKey // string key (e.g. 'on') or null if default servedflag.source // 'rule' | 'default' | 'holdout' | 'sticky' | 'not_found' | ...flag.reasons // string[] (human-readable reasons array)flag.ruleId // matched rule id or null// Typed evaluator variantsconst theme = server.forUser(ctx).getStringFlag('homepage_theme', 'default');const limit = server.forUser(ctx).getNumberFlag('rate_limit', 100);const config = server.forUser(ctx).getJsonFlag<PricingConfig>('pricing_config', { currency: 'USD', seats: 1 });// Browser client (context already bound at construction / identify)const flag2 = client.getBoolFlag('show_banner', false);LaunchDarkly's variationIndex is 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.
LaunchDarkly:
ldClient.track('purchase_completed', ctx, { orderId: 'ord_99' }, 49.99);A vs B:
// Browser: context already bound. LaunchDarkly's positional metric value// becomes `revenue` (money) or `value` (a plain numeric metric).client.track('purchase_completed', { revenue: 49.99 });// Server: pass context in payloadserver.track('purchase_completed', { context: { kind: 'user', key: 'u_123' }, revenue: 49.99 });// Server with forUser scopeserver.forUser({ kind: 'user', key: 'u_123' }).track('purchase_completed', { revenue: 49.99,});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.
LaunchDarkly:
import type { LDMultiKindContext } from 'launchdarkly-js-client-sdk';const multiCtx: LDMultiKindContext = { kind: 'multi', user: { kind: 'user', key: 'u_123', plan: 'pro' }, organization: { kind: 'organization', key: 'org_42', tier: 'enterprise' } };ldClient.variation('enterprise_feature', multiCtx, false);A vs B:
import type { MultiContext } from '@avsbhq/core';const multiCtx: MultiContext = { kind: 'multi', user: { kind: 'user', key: 'u_123', plan: 'pro' }, organization: { kind: 'organization', key: 'org_42', tier: 'enterprise' } };// In the dashboard, set hashAttribute to 'organization.key' to bucket by orgserver.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.
LaunchDarkly:
ldClient.on('change', (changes: Record<string, { current: unknown; previous: unknown }>) => { // changes is a map of flagKey → { current, previous }});A vs B:
// Subscribe to individual flag changesconst unsub = client.on('flagChange', ({ flagKey, previousValue, newValue }) => { console.log(flagKey, previousValue, '→', newValue);}); // Call unsub() to stop listening// Subscribe to any datafile update (e.g. to force a re-render)client.on('configUpdate', ({ publishedAt, reason }) => { // reason: 'poll' | 'stream' | 'manual'});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.
LaunchDarkly, Next.js bootstrap:
// Server componentimport { init } from '@launchdarkly/node-server-sdk';const ldClient = init(process.env.LD_SDK_KEY!);await ldClient.waitForInitialization();const bootstrapData = ldClient.allFlagsState(ctx);// Pass serialized state to clientA vs B, Next.js bootstrap:
// Server component (app/layout.tsx)import { fetchDatafile } from '@avsbhq/browser/server';const datafile = await fetchDatafile(process.env.AVSB_SDK_KEY!);// Pass datafile to the client-side AvsbProvider// Client provider (app/providers.tsx)'use client'import type { ReactNode } from 'react';import { AvsbProvider } from '@avsbhq/react';import type { FlagDatafile } from '@avsbhq/react';export function Providers({ datafile, children }: { datafile: FlagDatafile | null; children: ReactNode }) { return ( <AvsbProvider sdkKey={process.env.NEXT_PUBLIC_AVSB_SDK_KEY!} context={{ kind: 'user', key: 'anon' }} bootstrap={datafile ?? undefined} > {children} </AvsbProvider> );}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.
LaunchDarkly:
// Provide a StickyBucketingService implementation at initimport { createClient } from 'redis';import { RedisStickyBucketService } from './my-sticky-service';const redisClient = createClient({ url: process.env.REDIS_URL });const ldStickyServer = ld.init(process.env.LD_SDK_KEY!, { stickyBucketService: new RedisStickyBucketService(redisClient),});A vs B (@avsbhq/node also ships InMemoryStickyBucketService and RedisStickyBucketService, so a custom class is only needed for a store they do not cover):
import type { StickyBucketService, StickyAssignment } from '@avsbhq/core';class MyStickyService 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 { // { variationId, ruleId, ruleType, assignedAt } or 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 MyStickyService(),});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, so no SDK code change is needed. Exposure events fire with ruleType: 'holdout' so you can exclude held-out cohorts from A/B analysis.
A vs B, reading holdout source:
const flag = server.forUser(ctx).getBoolFlag('checkout_redesign', false);if (flag.source === 'holdout') { // This user is in the holdout group: they see the control across all flags // in this holdout. Do not count them in experiment metrics.}Cleanup
LaunchDarkly:
await ldClient.close();A vs B:
// Flush any buffered events and stop polling/streamingawait server.close(); // server SDKawait client.close(); // browser SDK (also calls flush() internally)Testing
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.
LaunchDarkly, test stub:
import { TestData } from 'launchdarkly-node-server-sdk';const td = TestData.dataSource();td.update(td.flag('show_banner').booleanFlag().variationForAll(true));const ldTestServer = ld.init('sdk-key', { dataSource: td });A vs B, test stub:
import { createMockClient } from '@avsbhq/test';const mockClient = createMockClient({ flags: { show_banner: true, homepage_theme: 'blue', rate_limit: 100, },});// mockClient conforms to the full AvsbClient interface// Inject it wherever your code expects an AvsbClientCutover checklist
Uninstall LaunchDarkly packages
Remove launchdarkly-node-server-sdk, launchdarkly-js-client-sdk, and launchdarkly-react-client-sdk from your package.json.
Install A vs B packages
Run 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
Replace your LaunchDarkly SDK key / client-side ID environment variables with your A vs B SDK key from the Environments page.
Migrate context construction
Replace LDContext / LDUser objects with A vs B EvalContext. Field names (kind, key, attributes) are identical; only the type import changes.
Replace variation calls
Replace each 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
Every variationDetail call becomes a plain getFlag call; the full Flag<T> envelope is always returned.
Migrate track calls
Replace client.track(event, ctx, data, metricValue) with client.track(event, { revenue }) for money or { value } for a plain numeric metric. 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)
Replace your StickyBucketingService implementation 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
Replace LaunchDarkly test fixtures with createMockClient from @avsbhq/test.
Verify and deploy
Run 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.