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

LaunchDarklyA vs B
LDContext / LDUserEvalContext (SingleContext or MultiContext)
context.kindcontext.kind (identical field name)
context.keycontext.key (identical field name)
LDMultiKindContextMultiContext 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 valueFlag<T>.value
LDEvaluationDetail.variationIndexFlag<T>.variationKey (string key, not numeric index)
LDEvaluationDetail.reasonFlag<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 metricA vs B metric event key (same concept, different config UI)
Holdout (enterprise)FlagDatafileHoldout (included in all plans)
Big segmentsNo 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:

TypeScript
import * as ld from 'launchdarkly-node-server-sdk';const ldServer = ld.init('sdk-your-server-key');await ldServer.waitForInitialization();// ldServer is ready
TypeScript5 lines

A vs B, Node server:

TypeScript
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 polling
TypeScript6 lines

LaunchDarkly, Browser:

TypeScript
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();
TypeScript5 lines

A vs B, Browser:

TypeScript
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();
TypeScript4 lines
Info

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.

One key per environment, used by both the server and browser SDKs. LaunchDarkly's separate server-side key and client-side ID both map to this single key.
  1. Environments lives in the sidebar on its own, not inside Settings.
  2. 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:

TypeScript
// 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 ctx
TypeScript4 lines

A vs B:

TypeScript
// 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, newContext
TypeScript11 lines
Tip

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

TypeScript
// 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.variationIndex
TypeScript6 lines

A vs B:

TypeScript
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);
TypeScript18 lines
Info

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:

TypeScript
ldClient.track('purchase_completed', ctx, { orderId: 'ord_99' }, 49.99);
TypeScript1 line

A vs B:

TypeScript
// 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,});
TypeScript11 lines

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:

TypeScript
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);
TypeScript4 lines

A vs B:

TypeScript
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);
TypeScript5 lines

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:

TypeScript
ldClient.on('change', (changes: Record<string, { current: unknown; previous: unknown }>) => {  // changes is a map of flagKey → { current, previous }});
TypeScript3 lines

A vs B:

TypeScript
// 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'});
TypeScript9 lines

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:

TypeScript
// 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 client
TypeScript6 lines

A vs B, Next.js bootstrap:

TypeScript React
// 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>  );}
TypeScript React23 lines

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:

TypeScript
// 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),});
TypeScript9 lines

A vs B (@avsbhq/node also ships InMemoryStickyBucketService and RedisStickyBucketService, so a custom class is only needed for a store they do not cover):

TypeScript
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(),});
TypeScript19 lines

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:

TypeScript
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.}
TypeScript5 lines

Cleanup

LaunchDarkly:

TypeScript
await ldClient.close();
TypeScript1 line

A vs B:

TypeScript
// Flush any buffered events and stop polling/streamingawait server.close();   // server SDKawait client.close();   // browser SDK (also calls flush() internally)
TypeScript3 lines

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:

TypeScript
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 });
TypeScript5 lines

A vs B, test stub:

TypeScript
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 AvsbClient
TypeScript11 lines

Cutover checklist

1

Uninstall LaunchDarkly packages

Remove launchdarkly-node-server-sdk, launchdarkly-js-client-sdk, and launchdarkly-react-client-sdk from your package.json.

2

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.

3

Swap SDK key

Replace your LaunchDarkly SDK key / client-side ID environment variables with your A vs B SDK key from the Environments page.

4

Migrate context construction

Replace LDContext / LDUser objects with A vs B EvalContext. Field names (kind, key, attributes) are identical; only the type import changes.

5

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.

6

Replace variationDetail calls

Every variationDetail call becomes a plain getFlag call; the full Flag<T> envelope is always returned.

7

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.

8

Migrate identify calls

client.identify(ctx) works the same way. Add client.alias(anonCtx, identifiedCtx) at login time to stitch anonymous and authenticated identities.

9

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.

10

Update tests

Replace LaunchDarkly test fixtures with createMockClient from @avsbhq/test.

11

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.

Was this helpful?