Advanced Optimisations for iOS and Android Optimizely SDKs
TL;DR
Overview
Optimizely Feature Experimentation provides native mobile SDKs for iOS (Swift) and Android (Kotlin/Java), enabling teams to run feature flags and A/B tests directly within mobile applications. Unlike web experimentation that operates on rendered HTML, mobile SDKs evaluate feature flags locally using a cached datafile, making decisions synchronous and performant with no network latency on each evaluation.
This guide covers the complete setup process for both platforms, from SDK installation through production-ready configuration including datafile management, feature flag evaluation, event tracking, and performance optimization.
Prerequisites
Before starting the SDK integration, ensure you have:
An Optimizely Feature Experimentation account with at least one feature flag configured
Your SDK Key from the Optimizely dashboard (Settings > Environments)
iOS: Xcode 14+, Swift 5+, minimum deployment target iOS 10.0
Android: Android Studio, Kotlin 1.6+, minimum SDK 21 (Android 5.0)
Installation
iOS: Swift Package Manager
Swift Package Manager is the recommended installation method for iOS projects.
Add the SDK dependency in your Package.swift:
dependencies: [
.package(
url: "https://github.com/optimizely/swift-sdk.git",
.upToNextMinor(from: "5.2.0")
)
]
Alternatively, add it through Xcode: File > Add Package Dependencies, then enter the repository URL https://github.com/optimizely/swift-sdk.git.
iOS: CocoaPods
For projects using CocoaPods, add to your Podfile:
pod 'OptimizelySwiftSDK', '~> 5.2'
Then install:
pod install
Android: Gradle
Add the Maven Central repository and SDK dependency in your app-level build.gradle.kts:
repositories {
mavenCentral()
}
dependencies {
implementation("com.optimizely.ab:android-sdk:5.1.1")
}
Sync your Gradle project after adding the dependency.
SDK Initialization
The SDK must be initialized before any feature flag evaluation can occur. Both platforms support synchronous and asynchronous initialization, each with different trade-offs.
flowchart TD
A[App Launch] --> B{Initialization Strategy}
B -->|Synchronous| C[Use Bundled/Cached Datafile]
B -->|Asynchronous| D[Download Fresh Datafile]
C --> E[SDK Ready Immediately]
D --> F{Download Success?}
F -->|Yes| G[SDK Ready with Fresh Config]
F -->|No| H[Fall Back to Cached Datafile]
H --> E
E --> I[Create User Context]
G --> I
I --> J[Evaluate Feature Flags]
J --> K[Track Events]Synchronous Initialization
Synchronous initialization uses a locally bundled or cached datafile, allowing the SDK to be ready immediately at app launch. This is ideal when startup speed is critical.
iOS (Swift):
import Optimizely
let optimizely = OptimizelyClient(sdkKey: "YOUR_SDK_KEY")
// Option 1: Use a bundled datafile from app resources
let datafilePath = Bundle.main.path(forResource: "datafile", ofType: "json")!
let datafileJSON = try String(contentsOfFile: datafilePath, encoding: .utf8)
try optimizely.start(datafile: datafileJSON)
// Option 2: Use bundled datafile + update config when new datafile downloads
try optimizely.start(datafile: datafileJSON, doUpdateConfigOnNewDatafile: true)
Android (Kotlin):
import com.optimizely.ab.android.sdk.OptimizelyManager
val optimizelyManager = OptimizelyManager.builder()
.withSDKKey("YOUR_SDK_KEY")
.build(applicationContext)
// Use a bundled datafile from res/raw/datafile.json
val optimizelyClient = optimizelyManager.initialize(
applicationContext,
R.raw.datafile
)
Asynchronous Initialization
Asynchronous initialization downloads a fresh datafile from the Optimizely CDN before the SDK becomes ready. This ensures the SDK uses the most current configuration but introduces a brief delay.
iOS (Swift):
let optimizely = OptimizelyClient(
sdkKey: "YOUR_SDK_KEY",
periodicDownloadInterval: 300 // Poll every 5 minutes
)
// Callback-based
optimizely.start { result in
switch result {
case .success:
let user = optimizely.createUserContext(userId: "user_123")
let decision = user.decide(key: "checkout_flow")
print("Variation: \(decision.variationKey ?? "default")")
case .failure(let error):
print("SDK initialization failed: \(error)")
}
}
// Async-await (iOS 13+)
Task {
do {
try await optimizely.start()
let user = optimizely.createUserContext(userId: "user_123")
let decision = user.decide(key: "checkout_flow")
} catch {
print("Initialization failed: \(error)")
}
}
Android (Kotlin):
val optimizelyManager = OptimizelyManager.builder()
.withSDKKey("YOUR_SDK_KEY")
.withDatafileDownloadInterval(15, TimeUnit.MINUTES)
.build(applicationContext)
optimizelyManager.initialize(applicationContext, null) { client ->
val user = client.createUserContext("user_123")
val decision = user?.decide("checkout_flow")
println("Variation: ${decision.variationKey}")
}
Initialization Strategy Comparison
Strategy | Startup Speed | Config Freshness | Offline Support | Best For |
|---|---|---|---|---|
Synchronous (bundled) | Instant | Stale until next download | Full offline support | Apps requiring instant startup |
Synchronous + auto-update | Instant | Updates in background | Full offline support | Balance of speed and freshness |
Asynchronous | Delayed (network) | Always fresh | Requires fallback | Apps where config accuracy is critical |
Datafile Management
The datafile is a JSON configuration containing all feature flag rules, experiment configurations, and targeting conditions. Proper datafile management is critical for mobile SDK performance.
flowchart LR
A[Optimizely CDN] -->|Download| B[SDK Datafile Manager]
B -->|Cache| C[Local Storage]
C -->|Load| D[SDK Config]
E[Bundled Datafile] -->|Fallback| D
D --> F[Flag Evaluation]
B -->|Poll Interval| ADatafile Polling
Both SDKs support periodic polling to keep the datafile up-to-date while the app is running.
iOS Polling Configuration:
// Poll every 5 minutes (minimum: 120 seconds / 2 minutes)
let optimizely = OptimizelyClient(
sdkKey: "YOUR_SDK_KEY",
periodicDownloadInterval: 300
)
// Disable polling (0 = disabled)
let optimizely = OptimizelyClient(
sdkKey: "YOUR_SDK_KEY",
periodicDownloadInterval: 0
)
Android Polling Configuration:
// Poll every 15 minutes (minimum: 15 minutes, enforced by Android JobScheduler)
val optimizelyManager = OptimizelyManager.builder()
.withSDKKey("YOUR_SDK_KEY")
.withDatafileDownloadInterval(15, TimeUnit.MINUTES)
.build(applicationContext)
Datafile Bundling for Offline Support
Always bundle a datafile with your app to ensure the SDK initializes reliably without network connectivity.
iOS: Add your datafile JSON to the app bundle and reference it via Bundle.main:
guard let path = Bundle.main.path(forResource: "optimizely_datafile", ofType: "json"),
let datafile = try? String(contentsOfFile: path, encoding: .utf8) else {
print("Bundled datafile not found")
return
}
try optimizely.start(datafile: datafile)
Android: Place the datafile in res/raw/ and reference it as a resource:
optimizelyManager.initialize(context, R.raw.optimizely_datafile) { client ->
// SDK ready with bundled datafile
// Will download fresh datafile in background if polling is enabled
}
Datafile Change Notifications
Register listeners to react when the SDK receives an updated datafile.
iOS (Swift):
let notificationId = optimizely.notificationCenter?.addUpdateConfigNotificationListener { notification in
print("Datafile updated - re-evaluating flags")
// Re-evaluate feature flags for current user if needed
}
Android (Kotlin):
optimizelyClient.addUpdateConfigNotificationHandler { notification ->
println("Datafile updated - re-evaluating flags")
// Re-evaluate feature flags for current user if needed
}
Datafile Polling Constraints
Platform | Minimum Interval | Default | Background Behavior |
|---|---|---|---|
iOS | 120 seconds (2 min) | 600 seconds (10 min) | Only polls in foreground |
Android | 900 seconds (15 min) | Disabled | Uses Android JobScheduler |
Feature Flag Evaluation
Feature flag evaluation on mobile follows the same decide pattern across both platforms. The SDK evaluates flags locally against the cached datafile with no network calls required.
Creating a User Context
Every flag evaluation requires a user context with a unique user ID and optional targeting attributes.
iOS (Swift):
let attributes: [String: Any] = [
"plan_type": "premium",
"app_version": "3.2.0",
"country": "US",
"logged_in": true
]
let user = optimizely.createUserContext(
userId: "user_abc_123",
attributes: attributes
)
Android (Kotlin):
val attributes = mapOf<String, Any>(
"plan_type" to "premium",
"app_version" to "3.2.0",
"country" to "US",
"logged_in" to true
)
val user = optimizelyClient.createUserContext("user_abc_123", attributes)
Evaluating a Single Flag
// iOS
let decision = user.decide(key: "new_checkout_flow")
let enabled = decision.enabled // Is the flag on for this user?
let variation = decision.variationKey // Which variation? (e.g., "control", "variation_1")
let variables = decision.variables // Flag variable values
// Access a specific variable
if let checkoutSteps: Int = decision.variables.getValue(jsonPath: "checkout_steps") {
configureCheckout(steps: checkoutSteps)
}
// Android
val decision = user?.decide("new_checkout_flow")
val enabled = decision.enabled
val variation = decision.variationKey
val variables = decision.variables
// Access a specific variable
val checkoutSteps = variables.getValue("checkout_steps", Int::class.java)
checkoutSteps?.let { configureCheckout(it) }
Evaluating Multiple Flags
When your app needs to evaluate several flags at once, use batch methods to reduce overhead.
// iOS: Evaluate specific flags
let decisions = user.decide(keys: ["checkout_flow", "recommendation_algo", "search_ranking"])
// iOS: Evaluate all enabled flags
let allDecisions = user.decideAll(options: [.enabledFlagsOnly])
for (key, decision) in allDecisions {
print("\(key): \(decision.variationKey ?? "off")")
}
// Android: Evaluate specific flags
val keys = listOf("checkout_flow", "recommendation_algo", "search_ranking")
val decisions = user?.decideForKeys(keys)
// Android: Evaluate all enabled flags
val options = listOf(OptimizelyDecideOption.ENABLED_FLAGS_ONLY)
val allDecisions = user?.decideAll(options)
allDecisions.forEach { (key, decision) ->
println("$key: ${decision.variationKey}")
}
Decide Options Reference
Control evaluation behavior with decide options:
Option | Description | Use Case |
|---|---|---|
| Prevents impression event dispatch | Pre-fetch decisions without tracking |
| Returns only enabled flags | UI rendering (skip disabled flags) |
| Bypasses sticky bucketing | Testing or debugging |
| Adds log messages to | Debugging flag evaluations |
| Omits variable values from result | Performance optimization when variables are not needed |
Setting Default Decide Options
Apply options globally to all decide calls:
// iOS: All decide calls will skip impression tracking by default
let optimizely = OptimizelyClient(
sdkKey: "YOUR_SDK_KEY",
defaultDecideOptions: [.disableDecisionEvent]
)
// Android: All decide calls will skip impression tracking by default
val options = listOf(OptimizelyDecideOption.DISABLE_DECISION_EVENT)
val optimizelyManager = OptimizelyManager.builder()
.withSDKKey("YOUR_SDK_KEY")
.withDefaultDecideOptions(options)
.build(context)
Event Tracking
Track conversion events to measure experiment outcomes. Events are batched and dispatched asynchronously to minimize impact on app performance.
Basic Event Tracking
iOS (Swift):
let user = optimizely.createUserContext(
userId: "user_abc_123",
attributes: ["plan_type": "premium"]
)
// Simple event
try? user.trackEvent(eventKey: "button_clicked")
// Event with revenue and custom properties
let eventProperties: [String: Any] = [
"category": "electronics",
"item_name": "wireless_headphones"
]
let eventTags: [String: Any] = [
"revenue": 7999, // Revenue in cents ($79.99)
"value": 79.99, // Numeric metric value
"$opt_event_properties": eventProperties // Custom properties
]
try? user.trackEvent(eventKey: "purchase_completed", eventTags: eventTags)
Android (Kotlin):
val user = optimizelyClient.createUserContext(
"user_abc_123",
mapOf("plan_type" to "premium")
)
// Simple event
user?.trackEvent("button_clicked")
// Event with revenue and custom properties
val eventProperties = hashMapOf<String, Any>(
"category" to "electronics",
"item_name" to "wireless_headphones"
)
val eventTags = hashMapOf<String, Any>(
"revenue" to 7999,
"value" to 79.99,
"\$opt_event_properties" to eventProperties
)
user?.trackEvent("purchase_completed", eventTags)
Event Tag Reference
Tag Key | Type | Description |
|---|---|---|
| Integer | Revenue in cents (e.g., 7999 = $79.99). Aggregated across all conversion events for revenue metrics. |
| Float | Numeric value for custom metrics. Used for non-revenue numeric tracking. |
| Map | Key-value pairs of custom event properties for segmentation and analysis. |
Event Batching
The Android SDK provides fine-grained control over event batching. Events are queued locally and dispatched in batches to reduce network overhead.
flowchart LR
A[trackEvent Call] --> B[Event Queue]
B -->|Batch Size Reached| C[Dispatch Batch]
B -->|Flush Interval| C
B -->|App Close| C
C --> D[Optimizely Event API]
D -->|Success| E[Remove from Queue]
D -->|Failure| F[Retry on Next Flush]Android Batch Configuration
val eventHandler = DefaultEventHandler.getInstance(applicationContext)
val batchProcessor = BatchEventProcessor.builder()
.withEventHandler(eventHandler)
.withBatchSize(10) // Dispatch after 10 events
.withFlushInterval(TimeUnit.SECONDS.toMillis(30)) // Or every 30 seconds
.build()
val optimizelyManager = OptimizelyManager.builder()
.withSDKKey("YOUR_SDK_KEY")
.withEventHandler(eventHandler)
.withEventProcessor(batchProcessor)
.withDatafileDownloadInterval(15, TimeUnit.MINUTES)
.build(applicationContext)
Batch Configuration Defaults
Parameter | Default | Description |
|---|---|---|
Batch size | 10 events | Maximum events before automatic dispatch |
Flush interval | 30,000 ms (30 sec) | Maximum time before dispatch |
Event queue | 1,000 events | Maximum queued events in memory |
Close timeout | 5,000 ms | Maximum wait time for |
Max payload | 3.5 MB | Optimizely rejects payloads exceeding this size |
Flushing Events on App Close
Always call close() on the Optimizely client when the app is backgrounded or terminated to flush any queued events and prevent data loss.
iOS (Swift):
// In AppDelegate or SceneDelegate
func applicationDidEnterBackground(_ application: UIApplication) {
optimizely.close()
}
Android (Kotlin):
// In Application class or Activity
override fun onStop() {
super.onStop()
optimizelyClient.close()
}
Production Configuration
A complete production-ready configuration example combining all best practices.
iOS (Swift):
import Optimizely
class OptimizelyService {
static let shared = OptimizelyService()
private(set) var client: OptimizelyClient!
func initialize() {
let settings = OptimizelySdkSettings(
segmentsCacheSize: 100,
segmentsCacheTimeoutInSecs: 600,
disableOdp: false
)
client = OptimizelyClient(
sdkKey: "YOUR_SDK_KEY",
periodicDownloadInterval: 300, // 5-minute polling
defaultLogLevel: .warning, // Reduce log noise
defaultDecideOptions: [],
settings: settings
)
// Initialize with bundled datafile for instant startup
if let path = Bundle.main.path(forResource: "datafile", ofType: "json"),
let datafile = try? String(contentsOfFile: path, encoding: .utf8) {
try? client.start(datafile: datafile, doUpdateConfigOnNewDatafile: true)
} else {
// Fall back to async if no bundled datafile
client.start { result in
if case .failure(let error) = result {
print("Optimizely init failed: \(error)")
}
}
}
// Listen for config updates
client.notificationCenter?.addUpdateConfigNotificationListener { _ in
print("Optimizely config updated")
}
}
}
Android (Kotlin):
import com.optimizely.ab.android.sdk.OptimizelyManager
import com.optimizely.ab.android.sdk.OptimizelyClient
import java.util.concurrent.TimeUnit
class OptimizelyService private constructor(context: Context) {
val client: OptimizelyClient
init {
val manager = OptimizelyManager.builder()
.withSDKKey("YOUR_SDK_KEY")
.withDatafileDownloadInterval(15, TimeUnit.MINUTES)
.withEventDispatchInterval(30, TimeUnit.SECONDS)
.build(context.applicationContext)
// Synchronous init with bundled datafile
client = manager.initialize(context, R.raw.datafile)
// Listen for config updates
client.addUpdateConfigNotificationHandler { _ ->
println("Optimizely config updated")
}
}
companion object {
@Volatile
private var instance: OptimizelyService? = null
fun initialize(context: Context): OptimizelyService {
return instance ?: synchronized(this) {
instance ?: OptimizelyService(context).also { instance = it }
}
}
fun getInstance(): OptimizelyService {
return instance ?: throw IllegalStateException(
"OptimizelyService not initialized. Call initialize() first."
)
}
}
}
Performance Optimization
Beyond basic setup, several SDK capabilities can significantly reduce network overhead, memory usage, and evaluation latency in production mobile apps.
Optimizing Decide Calls
Every decide() call dispatches an impression event by default. In screens where you evaluate flags for UI rendering but do not need to track impressions (pre-fetching, feature gating, conditional layouts), this creates unnecessary network traffic.
Suppress impression events for non-critical evaluations:
// iOS: Pre-fetch decisions without firing impressions
let options: [OptimizelyDecideOption] = [.disableDecisionEvent, .excludeVariables]
let decisions = user.decideAll(options: options)
// Only fire impressions when the user actually sees the experiment
let trackedDecision = user.decide(key: "checkout_flow")
// Android: Pre-fetch decisions without firing impressions
val options = listOf(
OptimizelyDecideOption.DISABLE_DECISION_EVENT,
OptimizelyDecideOption.EXCLUDE_VARIABLES
)
val decisions = user?.decideAll(options)
// Only fire impressions when the user actually sees the experiment
val trackedDecision = user?.decide("checkout_flow")
When to use each option:
Option | Network Savings | Use Case |
|---|---|---|
| Eliminates impression event per call | Pre-fetching, feature gating, conditional UI layout |
| Reduces decision payload size | When you only need enabled/variation, not JSON variables |
| Skips evaluation of disabled flags | Dashboard rendering, showing only active features |
| Maximum savings | Startup flag evaluation for app configuration |
Setting Default Decide Options Globally
Rather than passing options to every decide() call, set global defaults at initialization. You can still override per-call when needed.
// iOS: Global defaults - suppress impressions by default
let optimizely = OptimizelyClient(
sdkKey: "YOUR_SDK_KEY",
defaultDecideOptions: [.disableDecisionEvent]
)
// Override for specific calls where you DO want impression tracking
let trackedDecision = user.decide(key: "pricing_experiment", options: [])
// Android: Global defaults - suppress impressions by default
val defaultOptions = listOf(OptimizelyDecideOption.DISABLE_DECISION_EVENT)
val manager = OptimizelyManager.builder()
.withSDKKey("YOUR_SDK_KEY")
.withDefaultDecideOptions(defaultOptions)
.build(context)
User Profile Service and Sticky Bucketing
The SDK includes a built-in User Profile Service (UPS) that persists variation assignments on-device. This ensures users see the same variation across sessions (sticky bucketing). However, the default implementation has performance implications worth understanding.
How it works: On each decide() call, the SDK checks the UPS for a previously saved assignment before evaluating targeting rules. If found, it returns the cached variation immediately, skipping traffic allocation and audience evaluation entirely.
Performance trade-off: The UPS lookup happens synchronously on the main thread by default. For apps with many concurrent flag evaluations, this can add latency if the underlying storage is slow.
Disable sticky bucketing when not needed:
// iOS: Bypass UPS for this specific evaluation
let decision = user.decide(key: "onboarding_flow", options: [.ignoreUserProfileService])
// Android: Disable UPS entirely with a no-op implementation
class NoOpUserProfileService : UserProfileService {
override fun lookup(userId: String): Map<String, Any>? = null
override fun save(userProfile: Map<String, Any>) {}
}
val manager = OptimizelyManager.builder()
.withSDKKey("YOUR_SDK_KEY")
.withUserProfileService(NoOpUserProfileService())
.build(context)
When to keep UPS enabled: A/B tests where consistent bucketing matters (pricing experiments, checkout flows). When to disable: Feature rollouts, kill switches, or flags that do not run experiments.
Custom Event Dispatcher
The default event dispatcher handles offline queuing and retry automatically, but production apps with high event volumes benefit from a custom dispatcher.
Android custom dispatcher with WiFi-aware retry:
val eventHandler = object : EventHandler {
override fun dispatchEvent(logEvent: LogEvent) {
// Route events to your analytics pipeline
// Implement custom retry logic, deduplication, or batching
}
}
val manager = OptimizelyManager.builder()
.withSDKKey("YOUR_SDK_KEY")
.withEventDispatchInterval(60, TimeUnit.SECONDS)
.withEventHandler(eventHandler)
.build(context)
// Register for WiFi-aware event rescheduling
val eventRescheduler = EventRescheduler()
context.registerReceiver(
eventRescheduler,
IntentFilter(WifiManager.SUPPLICANT_CONNECTION_CHANGE_ACTION)
)
For Android, add the following to your AndroidManifest.xml to enable automatic event flushing on connectivity changes, reboots, and app updates:
<receiver
android:name="com.optimizely.ab.android.event_handler.EventRescheduler"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.net.wifi.supplicant.CONNECTION_CHANGE" />
</intent-filter>
</receiver>
Notification Listeners for Observability
Notification listeners let you hook into SDK internals for monitoring, analytics integration, and debugging without modifying core logic.
// iOS: Monitor all flag decisions
let decisionId = optimizely.notificationCenter?.addDecisionNotificationListener { type, userId, attributes, decisionInfo in
// Log to your analytics platform
let flagKey = decisionInfo["flagKey"] as? String ?? ""
let variation = decisionInfo["variationKey"] as? String ?? "none"
Analytics.track("flag_evaluated", properties: ["flag": flagKey, "variation": variation])
}
// iOS: Monitor outgoing events for debugging
let logEventId = optimizely.notificationCenter?.addLogEventNotificationListener { url, params in
print("Optimizely dispatching \(params.count) events to \(url)")
}
// iOS: Monitor config updates to trigger re-evaluation
let configId = optimizely.notificationCenter?.addUpdateConfigNotificationListener { _ in
// Datafile updated - re-evaluate critical flags
refreshFeatureFlags()
}
// Clean up when no longer needed
optimizely.notificationCenter?.removeNotificationListener(notificationId: decisionId!)
// Android: Monitor all flag decisions
optimizelyClient.notificationCenter.addNotificationListener(
NotificationCenter.NotificationType.Decision
) { args ->
val type = args[0] as String
val userId = args[1] as String
val decisionInfo = args[3] as Map<String, Any>
analytics.track("flag_evaluated", mapOf(
"flag" to (decisionInfo["flagKey"] ?: ""),
"variation" to (decisionInfo["variationKey"] ?: "none")
))
}
Listener Type | Triggers When | Use Case |
|---|---|---|
Decision | Any | Analytics integration, monitoring |
Track |
| Event validation, deduplication |
LogEvent | Event batch is dispatched to API | Network debugging, payload inspection |
ConfigUpdate | New datafile downloaded | Re-evaluate flags, cache invalidation |
Forced Decisions for QA and Testing
Forced decisions let you override bucketing for specific users without modifying experiments in the dashboard. This is invaluable for QA testing specific variations across all flag rules.
// iOS: Force a specific variation for testing
let user = optimizely.createUserContext(userId: "qa_tester_1")
let flagContext = OptimizelyDecisionContext(flagKey: "new_checkout")
let forceVariation = OptimizelyForcedDecision(variationKey: "variation_b")
user.setForcedDecision(context: flagContext, decision: forceVariation)
// This will always return variation_b, regardless of targeting
let decision = user.decide(key: "new_checkout")
// Override at the experiment-rule level for more precision
let ruleContext = OptimizelyDecisionContext(flagKey: "new_checkout", ruleKey: "experiment_1")
user.setForcedDecision(context: ruleContext, decision: forceVariation)
// Clean up after testing
user.removeAllForcedDecisions()
// Android: Force a specific variation for testing
val user = optimizelyClient.createUserContext("qa_tester_1")
val flagContext = OptimizelyDecisionContext("new_checkout", null)
val forceVariation = OptimizelyForcedDecision("variation_b")
user?.setForcedDecision(flagContext, forceVariation)
// This will always return variation_b
val decision = user?.decide("new_checkout")
// Clean up after testing
user?.removeAllForcedDecisions()
Forced decisions bypass audience conditions and traffic allocation, fire impression events normally (unless disableDecisionEvent is set), and are not persisted across app restarts.
Memory and Resource Management
Singleton pattern: Always use a single SDK instance per SDK key. Creating multiple instances duplicates the datafile in memory and multiplies polling and event dispatch overhead.
Dispose properly: Call close() not just on backgrounding, but also when the SDK is no longer needed (user logout in multi-tenant apps):
// iOS: Full cleanup
optimizely.close()
// Android: Full cleanup
optimizelyClient.close()
Reduce log verbosity in production:
// iOS: Only log warnings and errors
let optimizely = OptimizelyClient(
sdkKey: "YOUR_SDK_KEY",
defaultLogLevel: .warning
)
Disable ODP if not using Real-Time Audiences:
// iOS: Disable ODP to reduce network calls and memory
let settings = OptimizelySdkSettings(
segmentsCacheSize: 0,
segmentsCacheTimeoutInSecs: 0,
disableOdp: true
)
let optimizely = OptimizelyClient(
sdkKey: "YOUR_SDK_KEY",
settings: settings
)
Performance Optimization Checklist
flowchart TD
A[Performance Audit] --> B{High event volume?}
B -->|Yes| C[Set global disableDecisionEvent]
B -->|No| D{Large JSON variables?}
C --> D
D -->|Yes| E[Use excludeVariables option]
D -->|No| F{Running A/B tests?}
E --> F
F -->|No| G[Disable User Profile Service]
F -->|Yes| H[Keep UPS enabled]
G --> I{Using Real-Time Audiences?}
H --> I
I -->|No| J[Disable ODP]
I -->|Yes| K[Configure segment cache size]
J --> L[Set production log level]
K --> L
L --> M[Register EventRescheduler - Android]
M --> N[Add notification listeners for monitoring]SDK Feature Compatibility Matrix
Not all features are available in every SDK version. Reference this matrix when planning your minimum SDK version.
Feature | Android Version | iOS (Swift) Version |
|---|---|---|
CMAB (Contextual Multi-Armed Bandits) | 5.1.0+ | 5.2.0+ |
Advanced Audience Targeting (VUID) | 5.0.0+ | 5.0.0+ |
Forced Decisions | 3.13+ | 3.10+ |
OptimizelyConfig V2 | 3.11+ | 3.9+ |
Decide API / UserContext | 3.9+ | 3.7+ |
JSON Feature Variables | 3.6+ | 3.4+ |
Automatic Datafile Management | 1.0+ | 3.1+ |
Offline Event Persistence | 1.0+ | 3.1+ |
Common Pitfalls
1. Not Bundling a Datafile
Without a bundled datafile, the SDK cannot initialize offline. Users who open the app for the first time without connectivity will not receive any feature flag evaluations. Always include a datafile in your app bundle.
2. Calling decide() Before Initialization
Calling decide() before the SDK has finished initializing returns a default decision with enabled = false. Ensure initialization is complete before evaluating flags, especially with asynchronous initialization.
3. Ignoring the close() Method
Failing to call close() when the app is backgrounded or terminated causes queued events to be lost. This results in missing conversion data in your experiment results.
4. Setting Polling Intervals Too Low
Aggressive polling wastes battery and bandwidth. Respect platform minimums: 2 minutes on iOS, 15 minutes on Android. For most applications, 10-15 minute intervals provide a good balance.
5. Tracking Duplicate Events
Call trackEvent only once per user action, even if multiple experiments measure the same conversion. The SDK automatically attributes the event to all relevant experiments.
6. Not Updating User Attributes
User attributes set during createUserContext are used for audience targeting. If a user's attributes change (upgrade to premium, changes location), create a new user context with updated attributes for accurate targeting.
Summary
Setting up Optimizely's mobile SDKs requires careful consideration of initialization strategy, datafile management, event tracking, and performance tuning. Key takeaways:
Choose your initialization strategy based on whether startup speed or configuration freshness matters more for your app
Always bundle a datafile with your app for reliable offline initialization
Configure polling intervals within platform constraints (2 min iOS, 15 min Android) to balance freshness with battery usage
Use batch event processing on Android with appropriate batch sizes and flush intervals
Call
close()when the app backgrounds to prevent event data lossSuppress unnecessary impressions with
disableDecisionEventfor pre-fetch and feature gating scenariosExclude variables from decisions when you only need the enabled/variation state
Disable the User Profile Service for feature rollouts that do not require sticky bucketing
Disable ODP if you are not using Real-Time Audiences to reduce network overhead
Register notification listeners for production observability and analytics integration
Use forced decisions for QA testing specific variations without modifying experiments
For complete API reference and advanced configuration, consult the official Optimizely documentation for the Swift SDK and Android SDK.