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 Console and assigned on the device using a deterministic hash — no server round-trip is needed for assignment.How Experiments Work
- You create an experiment in the Console, with two to five variants (e.g., “control” and “variant_a”) and a weight for each (e.g., 50/50), and optionally a Traffic Allocation below 100% and targeting rules.
- When you start it, its definition is written to your app’s config. The SDK reads that config when it fetches remote config (see Config TTL and Refresh).
- When your code calls
getVariant(), the SDK decides on the device whether the user is in the experiment (platform, targeting rules, traffic allocation — see Who Is in an Experiment) and, if so, computes the variant locally using a deterministic hash. - The same user ID always gets the same answer for the same experiment — across sessions, on every platform, and offline.
- 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+. Flutter and React Native present these surfaces through the native SDK they wrap, so the same versions apply. Older SDK versions show the live surface.- 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.
- Start the experiment. The control entity must be published, not a draft, and only one running experiment can use a given entity.
- Your app keeps presenting the surface exactly as before — e.g.
AppDNA.presentPaywall(id:). No experiment code is involved.
getVariant) and:
- Treatment cohort → renders the treatment configuration in place of the live entity.
- Control cohort, devices on a platform the experiment does not target, and users on older SDK versions → render the live entity unchanged. So does a treatment user whose treatment configuration has not loaded.
- Users who are not in the experiment — outside its targeting rules or its traffic allocation — → render the live entity, and no exposure is recorded for them.
Assignment Algorithm
The SDK assigns users to variants with a MurmurHash3 (32-bit) hash, producing a stable bucket from 0 to 9999:
The bucket maps to a variant through the variant weights, taken in the experiment’s variant order:
identify(), the user is bucketed under the new ID.
getVariant() makes no network request. It works offline whenever the device holds the experiment definition — from the last fetch, the on-device cache or the bundled config (see Offline Experiment Assignment).Who Is in an Experiment
Supported on: iOS SDK
1.0.82+ / Android SDK 1.0.54+ (Flutter 1.0.20+, React Native 1.0.19+, which use the native SDK they wrap), with the AppDNA server from the same release. Older SDK versions ignore the traffic allocation and the targeting rules below and assign a variant to every user on a targeted platform.- The experiment is running.
- It targets this platform (iOS, Android or both).
-
Every targeting rule passes. You set them in the Console’s Targeting step, and an admin or owner of the organization can change them later on the experiment page: Edit Configuration while the experiment is a draft or paused (served when you start or resume it), and Edit targeting on the Targeting card while it is running (served at once). Devices apply a change at their next config fetch; a user who still matches keeps the same variant, a user who no longer matches gets no variant from then on, and exposures already recorded stay in the results. The same holds for
targetingsent toPUT /api/v1/experiments/<id>. The rules: -
The user is inside the Traffic Allocation: a second, independent hash puts each user in an allocation bucket,
and the user is in the experiment when
allocation_bucket < traffic_allocation × 10000(at 30%, buckets 0-2999). An experiment without a traffic allocation (one saved before the field existed) includes everyone; an allocation that is not a number includes no one. Because the variant uses a different hash, the allocation does not change the variant split, and raising the allocation keeps every user who was already in, with the same variant.
getVariant() returns nil / null, isInVariant() returns false, getExperimentConfig() returns nil / null, a servable surface shows its live entity, and no exposure is recorded — so these users are not counted in the results. Because the answer depends on traits, the app version and the region, it can change when those change (for example after identify(userId, traits)); an exposure already recorded stays recorded until the app restarts or reset() is called.
There is no segment targeting: the API refuses a non-empty targeting.segments. Use user trait conditions instead.
SDK Usage
The argument togetVariant() is the experiment’s ID, not its name. The Console shows it, with a copy button, under the experiment’s name on its page and in the ID column of the experiments list (it is also the UUID in the page URL, /experiments/<id>). The value it returns is the variant ID you set in the Console, for example "control" or "variant_a".
- iOS
- Android
- Flutter
- React Native
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.getVariant() returns nil / null when the experiment is not in the config the SDK holds (never started, completed, archived, or not fetched yet), when it is paused, when it does not target the current platform, or when the user is outside its targeting rules or traffic allocation.
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 sends anexperiment_exposure event the first time it assigns a variant for an experiment: on a call to getVariant(), isInVariant() or getExperimentConfig(), or on presenting a surface under a servable experiment. This means:
- Calling
getVariant("YOUR_EXPERIMENT_ID")again does not record another exposure while the app keeps running. - The SDK remembers exposures in memory only. After the app is restarted, or after
AppDNA.reset(), the next assignment records a new exposure. A new session within the same app run does not. - No exposure is recorded when
getVariant()returnsnil/null— including for users outside the experiment’s targeting or traffic allocation. - There is no public
trackExposure()method and no way to defer or disable automatic exposure.
Experiment Lifecycle
Experiments progress through a defined lifecycle:
Starting an experiment fails if a variant references an entity that does not exist, if the control entity is still a draft, or if another running experiment of the same type uses one of the same entities.
Automatic transitions
While an experiment is running, AppDNA analyzes it about once an hour:- If one of its stop-loss rules fires, it is paused.
- When it reaches its maximum runtime (the Console’s default is 90 days), it is completed. If the analysis has found a winner, that winner is recorded.
What happens when an experiment is completed?
In the Console, Complete opens a dialog where you pick the outcome:- No winner — keep the current experience. The control is the live experience, so “the control won” is completed this way too. Nothing is rolled out.
- A treatment variant, shown with its latest result on the primary metric (value, uplift, p-value). A treatment can be picked only if the latest results show it as a statistically significant improvement over control on the primary metric, in the direction the metric should move, with its confidence interval clear of the control value. Treatments that do not qualify are shown disabled with the reason.
POST /api/v1/experiments/<id>/complete), send {"winner_variant": "<variant id>"} or {"winner_variant": null} (or no winner_variant) for no winner. A variant that does not qualify — or the control — is refused with a 400 that gives the reason, and the experiment stays as it was. Completing is refused before the experiment’s minimum runtime has passed (the Console’s default is 7 days).
- The winner, if any, is recorded on the experiment.
- The experiment is removed from your app’s config. After their next config fetch, devices get
nil/nullfromgetVariant()and show the live entity of a servable experiment. - Until a device fetches config again, it keeps the experiment definition it already has and keeps assigning variants and recording exposures. A config bundle generated while the experiment was running still contains it, so a fresh install that cannot reach the network uses that definition until its first successful fetch.
- If Auto-Deploy Winner is on, the winner is rolled out (see below).
Auto-Deploy Winner
For paywall, onboarding flow, in-app message and survey experiments, the experiment’s Safety step has an Auto-Deploy Winner switch, and the experiment page’s configuration card lets you change it while the experiment is a draft or paused (not while it is running). When the experiment completes with a winner — completed by you, or automatically at its maximum runtime with a measured winner — the winning variant’s content is copied onto the live entity the control points at and published. The live entity then has exactly the winner’s content: a setting the winner leaves empty is cleared on the live entity too (for example, a winning paywall without translations removes the live paywall’s translations, and a winning onboarding flow without a branching graph removes the live flow’s graph). The entity keeps its ID (sopresentPaywall(id:), startOnboarding(flowId:) and message and survey triggers keep working), its name and, for a paywall, its placement; an in-app message also keeps its priority and its start and end dates. The publish runs the surface’s usual publish checks, the previous version is kept in the entity’s version history (restore it there to undo), and the rollout is written to the experiment’s timeline and the audit log.
- Deploy trigger: automatically — the rollout happens at completion.
- Deploy trigger: after approval — the completed experiment shows a Deploy winner button; the rollout happens when an admin clicks it.
- Notify team on deploy — an inbox notification for a rollout, an approval that is waiting, and a rollout that failed.
auto_deploy_config.enabled. Roll those winners out yourself — for example, change your app code to the winning branch.
Statistical Analysis
All statistical analysis is performed server-side. The dashboard displays, per variant:- Sample size — the number of exposed users
- Rate (or revenue per user, for a revenue metric) with a confidence interval (95% by default)
- Uplift — percentage change versus control
- P-value, power and whether the result is significant
The tests are fixed-horizon, frequentist tests: a two-proportion z-test for rate metrics and Welch’s t-test for revenue per user, with a Holm-Bonferroni correction when there are several treatment variants. AppDNA does not use sequential testing. To limit the effect of looking early, the analysis reports a winner only once that variant has reached the sample size needed to detect the minimum detectable effect, every variant has reached the minimum sample size, and a sample-ratio-mismatch check passes.
Best Practices
-
Always handle the default case. If
getVariant()returnsnil/null(experiment not started, paused, completed, archived, not targeting this platform, or the user is outside its targeting or traffic allocation), fall back to the control experience. -
Use the experiment’s ID, not its name.
getVariant()looks experiments up by the generated ID. Choose descriptive variant IDs (letters, digits and underscores) such as"annual_first"— those are the values your code compares against. -
Track custom conversion events. In addition to exposure tracking, use
AppDNA.track()to record conversion events (e.g.,"purchase_completed"), and use the same event name as the experiment’s metric. - Test with a sandbox key first. An experiment belongs to the app, not to an environment: once started, it is served to builds using a sandbox key and builds using a production key alike. Events sent with a sandbox key are stored apart from production events, and experiment results are computed from production events only.
- Decide the allocation before you start. Variants, weights, traffic allocation and the primary metric cannot be changed while an experiment is running or paused. To change them, complete the experiment and start a new one.

