Advanced Optimisations for iOS and Android Optimizely SDKs

Loading...

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| A

Datafile 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

disableDecisionEvent

Prevents impression event dispatch

Pre-fetch decisions without tracking

enabledFlagsOnly

Returns only enabled flags

UI rendering (skip disabled flags)

ignoreUserProfileService

Bypasses sticky bucketing

Testing or debugging

includeReasons

Adds log messages to reasons array

Debugging flag evaluations

excludeVariables

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

revenue

Integer

Revenue in cents (e.g., 7999 = $79.99). Aggregated across all conversion events for revenue metrics.

value

Float

Numeric value for custom metrics. Used for non-revenue numeric tracking.

$opt_event_properties

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 close() to flush

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

disableDecisionEvent

Eliminates impression event per call

Pre-fetching, feature gating, conditional UI layout

excludeVariables

Reduces decision payload size

When you only need enabled/variation, not JSON variables

enabledFlagsOnly

Skips evaluation of disabled flags

Dashboard rendering, showing only active features

disableDecisionEvent + excludeVariables

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 decide() call returns

Analytics integration, monitoring

Track

trackEvent() is called

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 loss

  • Suppress unnecessary impressions with disableDecisionEvent for pre-fetch and feature gating scenarios

  • Exclude 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.