From Split
Split.io's architecture (local evaluation against a downloaded split definition file, impression events for exposure tracking, and an explicit track call for metrics) maps closely to A vs B. The biggest conceptual shift is the removal of "traffic types": Split uses traffic types (user, account, device) as a top-level SDK primitive, while A vs B handles the same concept through context kinds on EvalContext. Your split keys, event types, and attribute names transfer directly.
Concept mapping
| Split.io | A vs B |
|---|---|
| Split (feature flag) | Feature flag (a hyphen in a split name becomes an underscore in the key) |
| Treatment | Flag<T>.variationKey (same concept; string key) |
getTreatment(key, splitName, attributes) | server.forUser(ctx).getStringFlag(key, 'control').variationKey |
getTreatmentWithConfig(key, splitName) | server.forUser(ctx).getJsonFlag<T>(splitName, {}).value |
getTreatments(key, splitNames, attributes) | client.getAllFlags() (all flags; no batched eval) |
| Traffic type (user, account, device) | Context kind (kind: 'user' / 'organization' / 'device') |
| Matching key | context.key |
| Bucketing key | context.key (or a custom hashAttribute per rule) |
| Attributes | Top-level properties on EvalContext |
client.track(trafficType, key, eventType, value, properties) | server.track(eventKey, { context, value?, properties? }) |
| Impression listener | client.on('exposure', handler) |
SplitFactory | new AvsbServer({ sdkKey }) |
factory.client(key) | server.forUser({ kind: 'user', key }) |
client.destroy() | server.close() |
| No holdout concept | FlagDatafileHoldout (available on all plans) |
Client construction
Split uses a SplitFactory to produce clients, with separate server and browser factories. A vs B uses a single AvsbServer / AvsbClient constructor.
Split.io, Node server:
import { SplitFactory } from '@splitsoftware/splitio';const factory = SplitFactory({ core: { authorizationKey: 'YOUR_SERVER_SIDE_API_KEY', }, impressionListener: { logImpression: (impressionData: { impression: unknown }) => { console.log(impressionData.impression); }, },});const splitClient = factory.client();await splitClient.ready();A vs B, Node server:
import { AvsbServer } from '@avsbhq/node';const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! });const result = await server.onReady();// Wire exposure events (replaces impressionListener)server.on('exposure', ({ experimentId, variationId, visitorId, properties }) => { console.log({ experimentId, variationId, visitorId, flagKey: properties?.flagKey });});Split.io, Browser:
import { SplitFactory } from '@splitsoftware/splitio';const factory = SplitFactory({ core: { authorizationKey: 'YOUR_BROWSER_API_KEY', key: 'u_123', },});const splitClient = factory.client();await splitClient.ready();A vs B, Browser:
import { AvsbClient } from '@avsbhq/browser';const client = new AvsbClient({ sdkKey: 'sdk_production_xxxxxxxxxxxxxxxx', context: { kind: 'user', key: 'u_123' },});await client.onReady();Identity model
Split passes the matching key on every evaluation call. A vs B follows the same per-call pattern on the server via forUser. On the browser client, the context is bound at construction and mutated via explicit methods.
Split.io:
// Key passed per callconst treatment = splitClient.getTreatment('u_123', 'show_banner', { plan: 'pro' });// No partial attribute update: pass new attributes on each callA vs B:
// Server: context per callconst flag = server.forUser({ kind: 'user', key: 'u_123', plan: 'pro' }) .getBoolFlag('show_banner', false);// Browser: bound context, explicit mutationsclient.identify({ kind: 'user', key: 'u_123', plan: 'pro' });client.updateAttributes({ plan: 'enterprise' }); // partial patch// Anonymous → identified stitching at sign-up. Synchronous: it records the// moment, it does not move past decisions.client.alias( { kind: 'user', key: 'anon_xyz' }, { kind: 'user', key: 'u_123' });Flag evaluation
Split's getTreatment returns a treatment string ('on', 'off', or a named variant). A vs B surfaces the treatment as Flag<T>.variationKey alongside the typed .value. For boolean splits (on/off), use getBoolFlag; for named treatments, use getStringFlag and read .variationKey.
Split.io:
// Boolean splitconst treatment = splitClient.getTreatment('u_123', 'show_banner');if (treatment === 'on') { /* ... */ }// Treatment with config (dynamic config attached to variation)const result = splitClient.getTreatmentWithConfig('u_123', 'checkout_redesign');// result.treatment, result.config (JSON string)const config = JSON.parse(result.config ?? '{}');// Batch evaluationconst treatments = splitClient.getTreatments('u_123', ['flag_a', 'flag_b']);A vs B:
// Boolean flagconst flag = server.forUser({ kind: 'user', key: 'u_123' }) .getBoolFlag('show_banner', false);if (flag.isEnabled()) { /* ... */ }// flag.variationKey → 'on' | 'off' | null// Flag with JSON config: the JSON value is directly typedinterface CheckoutConfig { ctaText: string; showPromo: boolean }const checkoutFlag = server.forUser({ kind: 'user', key: 'u_123' }) .getJsonFlag<CheckoutConfig>('checkout_redesign', { ctaText: 'Buy', showPromo: false });const { ctaText, showPromo } = checkoutFlag.value;// All flags (no batched eval needed; all evaluate synchronously from in-memory datafile)const allFlags = client.getAllFlags();Split returns 'control' as the treatment when the split definition is not found or evaluation fails. A vs B returns the defaultValue you pass to getFlag and sets source: 'not_found' or source: 'default' on the result. Check flag.exists() to distinguish "flag not found" from "flag found but default served".
Tracking events
Split's track takes traffic type, key, event type, value, and properties as positional arguments. A vs B uses a named payload object and reads context from forUser (server) or the bound context (browser).
Split.io:
splitClient.track('user', 'u_123', 'purchase_completed', 49.99, { orderId: 'ord_99' });A vs B:
// Server. `revenue` is money in major units; `value` is the separate// numeric-metric column. Conversion events do not store properties, so// Split's `orderId` above has no A vs B equivalent on this call.server.track('purchase_completed', { context: { kind: 'user', key: 'u_123' }, revenue: 49.99,});// Browser (context bound)client.track('purchase_completed', { revenue: 49.99 });Multi-context
Split's traffic types (user, account, device) are a similar concept to A vs B's context kinds, but in Split the traffic type is resolved by passing a different key namespace. In A vs B, multi-context is a first-class data structure: you pass all entity contexts together and rules declare which context kind they hash on.
Split.io, account-level treatment:
// Use the account ID as the key to get account-scoped trafficconst treatment = splitClient.getTreatment('org_42', 'enterprise_feature', { tier: 'enterprise' });A vs B, multi-context:
import type { MultiContext } from '@avsbhq/core';const ctx: MultiContext = { kind: 'multi', user: { kind: 'user', key: 'u_123' }, organization: { kind: 'organization', key: 'org_42', tier: 'enterprise' },};// In the dashboard, set hashAttribute to 'organization.key' for org-scoped rulesserver.forUser(ctx).getBoolFlag('enterprise_feature', false);Streaming updates
Split uses a streaming connection (SSE) for real-time split definition updates. A vs B uses configurable polling with optional SSE for the browser client. The behavioral outcome is the same: the client picks up flag changes without a deployment.
A vs B, update listener:
client.on('configUpdate', ({ publishedAt, reason }) => { // reason: 'poll' | 'stream' | 'manual'});client.on('flagChange', ({ flagKey, previousValue, newValue }) => { // A specific flag value changed});Bootstrap / SSR
Split does not provide a native SSR bootstrap mechanism for Next.js. A vs B supports a full bootstrap pattern where the datafile is pre-fetched on the server and passed to the client provider, eliminating any loading state.
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!);<AvsbProvider sdkKey="..." context={ctx} bootstrap={datafile ?? undefined}> <YourApp /></AvsbProvider>Holdouts
Split does not have a built-in holdout concept. A vs B includes holdouts on all plans. Create a holdout in the dashboard, associate flags with it, and the SDK automatically routes held-out users to the control variation with source: 'holdout'.
Cleanup
Split.io:
splitClient.destroy();A vs B:
await server.close(); // flushes buffered events, stops pollingTesting
Split.io, localhost mode:
const factory = SplitFactory({ core: { authorizationKey: 'localhost' }, features: { show_banner: 'on', checkout_redesign: 'treatment_a', },});A vs B, mock client:
import { createMockClient } from '@avsbhq/test';const mock = createMockClient({ flags: { show_banner: true, checkout_redesign: { ctaText: 'Get started', showPromo: true }, },});Cutover checklist
Remove Split packages
Uninstall @splitsoftware/splitio and related packages.
Install A vs B packages
Install @avsbhq/node, @avsbhq/browser, and @avsbhq/react as needed.
Replace SplitFactory with AvsbServer
Replace SplitFactory({ core: { authorizationKey } }) with new AvsbServer({ sdkKey }).
Migrate traffic types to context kinds
Replace per-call key / traffic-type pairs with EvalContext objects that use kind to distinguish user, account, device, and other entity types.
Replace getTreatment calls
Replace client.getTreatment(key, splitName, attrs) with server.forUser(ctx).getBoolFlag(splitName, false) (boolean splits) or getStringFlag / getJsonFlag as appropriate.
Migrate impressionListener
Replace the impressionListener factory option with server.on('exposure', handler).
Migrate track calls
Replace client.track(trafficType, key, eventType, value, props) with server.track(eventType, { context, value, properties }).
Update localhost / test mode
Replace Split's localhost mode with createMockClient from @avsbhq/test.
Verify and deploy
Run npm run build and npx tsc --noEmit. Confirm flag evaluations and events in the A vs B dashboard before promoting to production.