Skip to main content

Running A/B Experiments

AppDNA experiments let you test different experiences with your users and measure which variant performs best. Experiments are created in the dashboard and assigned on-device using a deterministic hash — no server round-trip required.

How Experiments Work

  1. You create an experiment in the dashboard, defining variants (e.g., “control” and “variant_a”) and traffic allocation (e.g., 50/50).
  2. The experiment definition is included in the config bundle that the SDK downloads.
  3. When your code calls getVariant(), the SDK computes the assignment locally using a deterministic hash.
  4. The same user always gets the same variant — across sessions, across platforms, and even offline.
There are two ways to act on that assignment:
  • Servable surface experiments — test a whole managed surface (paywall, onboarding flow, in-app message, or survey) with no app code. See below.
  • Code-level experiments — read the variant with getVariant() and branch yourself. See SDK Usage.

Servable Surface Experiments

Supported on: iOS SDK 1.0.65+ / Android SDK 1.0.37+. On Flutter and React Native the control variant is shown; older SDK versions also fall back to the live surface.
You can A/B test an entire managed surface without writing any branching code:
  1. In the Console, create an experiment on the surface. The control variant references the live entity you already present; the treatment variant carries an alternate configuration of that same surface.
  2. Start the experiment.
  3. Your app keeps presenting the surface exactly as before — e.g. AppDNA.presentPaywall(id:). No experiment code is involved.
On presentation, the SDK deterministically buckets the user (same MurmurHash3 path as getVariant) and:
  • Treatment cohort → renders the treatment configuration in place of the live entity.
  • Control cohort, users not in the audience, and users on older SDK versions → render the live entity unchanged.
Exposure is recorded automatically the first time the surface is presented, so conversions attribute to the experiment with no extra calls. The treatment configuration is delivered only inside the experiment payload, so a user who isn’t bucketed into the treatment can never receive it (cohort isolation). Use servable experiments for the content and design of managed surfaces; use the code-level API below for feature flags, custom UI, or logic that isn’t a managed surface.

Assignment Algorithm

The SDK assigns users to variants with a deterministic hash of the user and experiment identifiers, producing a stable bucket from 0 to 9999: The resulting bucket maps to a variant based on the traffic allocation configured in the dashboard:
Because the mapping is deterministic, a given user always lands in the same bucket for the same experiment, across every platform, session, and app version — with no server round-trip.
Because the assignment is deterministic, the same user always gets the same variant for the same experiment. No randomness, no server dependency, and it works fully offline once the experiment definition is cached.

SDK Usage

getVariant() is synchronous on iOS and Android, and asynchronous on Flutter (Future<String?>) and React Native (Promise<string | null>). On Flutter and React Native you must await the result before comparing it, or the comparison will always be false.

Exposure Tracking

Exposure tracking records that a user was shown a particular experiment variant. This is critical for accurate experiment analysis — you only want to measure outcomes for users who actually saw the variant. Exposure is tracked automatically. The SDK records an exposure event once per session the first time getVariant() is called for a given experiment. This means:
  • Calling getVariant("paywall-test") multiple times in the same session only records one exposure.
  • A new exposure is recorded when the user starts a new session.
  • There is no public trackExposure() method and no way to defer or disable automatic exposure — exposure fires on the first getVariant() call for each experiment.

Experiment Lifecycle

Experiments progress through a defined lifecycle:

What happens when an experiment is stopped?

When you complete an experiment and declare a winner in the dashboard:
  1. The config bundle is regenerated with the experiment marked as completed.
  2. All users now receive the winning variant from getVariant(), regardless of their original assignment.
  3. Exposure events are no longer tracked for completed experiments.

Statistical Analysis

All statistical analysis is performed server-side. The dashboard displays:
  • Conversion rates per variant with confidence intervals
  • Statistical significance (p-value) based on a frequentist approach
  • Sample size and exposure counts per variant
  • Lift (percentage improvement of treatment over control)
You do not need to implement any analytics logic in your app. The SDK handles exposure tracking and the server handles the math.
AppDNA uses a sequential testing methodology that lets you check results at any time without inflating your false positive rate. You do not need to wait for a predetermined sample size before looking at results.

Best Practices

  1. Always handle the default case. If getVariant() returns nil/null (experiment not found or archived), fall back to the control experience.
  2. Use meaningful experiment keys. Choose descriptive keys like "onboarding-v2" or "paywall-annual-price" rather than generic names like "test-1".
  3. Track custom conversion events. In addition to exposure tracking, use AppDNA.track() to record conversion events (e.g., "purchase_completed") that you configure as goals in the dashboard.
  4. Test in sandbox first. Run experiments in the sandbox environment before deploying to production. Sandbox experiments are completely isolated from production data.
  5. Do not change traffic allocation mid-experiment. Changing the allocation while an experiment is running can bias your results. If you need to adjust, consider stopping the experiment and starting a new one.