
GrowthBook - Kotlin SDK
-
Lightweight and fast
-
Kotlin Multiplatform (Android, iOS, macOS (Apple Silicon), JVM, JS, Wasm)
- Android version 21 & above
- JDK 17 & Above
- iOS (iosX64, iosArm64, iosSimulatorArm64)
-
Use your existing event tracking (GA, Segment, Mixpanel, custom)
-
Adjust variation weights and targeting without deploying new code
See CHANGELOG.md for version history.
Setup
App level build.gradle (Groovy DSL):
repositories {
mavenCentral()
}
dependencies {
implementation 'io.growthbook.sdk:GrowthBook:7.9.0'
implementation 'io.growthbook.sdk:NetworkDispatcherKtor:1.2.0'
implementation 'io.growthbook.sdk:NetworkDispatcherOkHttp:1.1.1'
}
If you are not sure which dispatcher to choose we recommend to use network dispatcher based on Ktor.
The main class of NetworkDispatcherKtor artifact is GBNetworkDispatcherKtor while the main class of NetworkDispatcherOkHttp artifact is GBNetworkDispatcherOkHttp.
If you are using other network client for example android-lite-http and don't want to have any other network client in your application,
you can provide your own implementation of NetworkDispatcher based on your network client.
Add Internet Permission to your AndroidManifest.xml, if not already added
<uses-permission android:name="android.permission.INTERNET" />
Integration
Integration is super easy:
- Create a Growth Book API key
- At the start of your app, do SDK Initialization as per below
Now you can start/stop tests, adjust coverage and variation weights, and apply a winning variation to 100% of traffic,
all within the Growth Book App without deploying code changes to your site.
initialize() method should be called in order to obtain SDK instance:
var sdkInstance: GrowthBookSDK = GBSDKBuilder(
apiKey = <API_KEY>,
hostURL = <GrowthBook_URL>,
attributes = < Hashmap >,
trackingCallback = { gbExperiment, gbExperimentResult -> },
encryptionKey = <String?>,
networkDispatcher = <NetworkDispatcher>,
).initialize()
If you are accessing features the first time there will be no features right after initialize() method call because features are not got from Backend yet. If you need to access features as soon as possible, you need to use GBCacheRefreshHandler. You can pass your implementation of GBCacheRefreshHandler through setRefreshHandler() method.
Threading: the fetched payload is processed on a background dispatcher, so GBCacheRefreshHandler is invoked off the main thread. Marshal back to your UI thread yourself if the callback touches UI state. An exception thrown by your handler is caught and logged, not propagated (so it cannot crash the background scope). feature()/run() are safe to call from any thread and always evaluate against a single consistent snapshot of the loaded state.
There are additional properties which can be setup at the time of initialization
.setEnabled(true)
.setQAMode(true)
.setForcedVariations(<HashMap>)
.setInitialFeatures(<GBFeatures>)
.setInitialPayload(<String>)
.setCacheMaxAge(<Long>)
.initialize()
Bundled fallback features (setInitialFeatures)
For a robust offline-first setup, you can bundle a known-good features payload (snapshotted from the API at build time) and seed the SDK with it at init. This gives flags a valid value from the first millisecond — on first installs, offline launches, and empty/corrupt cache — instead of falling back to hardcoded code defaults.
val bundledFeatures: GBFeatures = mapOf(
"dark-mode" to GBFeature(defaultValue = GBBoolean(false)),
"new-checkout" to GBFeature(defaultValue = GBBoolean(true)),
)
var sdkInstance: GrowthBookSDK = GBSDKBuilder(
apiKey = <API_KEY>,
hostURL = <GrowthBook_URL>,
attributes = hashMapOf(),
trackingCallback = { _, _ -> },
networkDispatcher = GBNetworkDispatcherKtor(),
)
.setInitialFeatures(bundledFeatures)
.initialize()
The seeded features are applied immediately. The normal cache/network refresh still runs on top and overwrites the seed as fresher data arrives. Effective precedence: network > disk cache > seed > code defaults.
Bundled fallback payload (setInitialPayload)
setInitialFeatures takes an already-decoded feature map, so bundling a snapshot means decrypting it at build time —
shipping plaintext feature definitions inside the app even when payload encryption is on. When the snapshot is encrypted,
or carries more than features — saved groups, contextual bandit definitions, or encrypted variants of any of them —
seed the raw API payload instead: the exact JSON body the features endpoint returns, snapshotted into your assets
at build time.
var sdkInstance: GrowthBookSDK = GBSDKBuilder(
apiKey = <API_KEY>,
hostURL = <GrowthBook_URL>,
attributes = hashMapOf(),
trackingCallback = { _, _ -> },
encryptionKey = <String?>,
networkDispatcher = GBNetworkDispatcherKtor(),
)
.setInitialPayload(readBundledJsonFromAssets())
.initialize()
This matters for contextual bandits in particular: a bandit rule is inert without its definitions, so a bundled payload
for a bandit-driven feature must go through setInitialPayload — otherwise the rule falls back to its marginal weights
until the network responds.
Like setInitialFeatures, this is only a seed and the same precedence applies. A payload that cannot be parsed or
decrypted is logged and ignored rather than failing initialization. If both setters are used, the explicit features win
over the payload's.
Upgrading from 6.x: Persistent caching is now implemented on every target — Android, Apple (iOS/macOS) and the JVM (on disk), and JS and wasmJs (browser localStorage). The legacy FeatureCache.txt → FeatureCache_<clientKey>.txt migration applies to Android only, so this upgrade note does not apply to the other targets.
Custom cache layer (setCachingLayer)
By default the SDK caches feature definitions in the built-in per-platform storage described above. To make GrowthBook persist through your own storage instead — a shared KMP key/value store, encrypted storage, or one place to clear/reset all cached state — provide a GBCachingLayer:
class MyCachingLayer : GBCachingLayer {
override fun saveContent(fileName: String, content: String) = myKvStore.put(fileName, content)
: String? = myKvStore.(fileName)
}
sdkInstance: GrowthBookSDK = GBSDKBuilder(
apiKey = <API_KEY>,
hostURL = <GrowthBook_URL>,
attributes = hashMapOf(),
trackingCallback = { _, _ -> },
networkDispatcher = GBNetworkDispatcherKtor(),
)
.setCachingLayer(MyCachingLayer())
.initialize()
Values are opaque JSON strings keyed by filename — persist and return them verbatim. When set, the custom layer replaces the built-in cache for both feature definitions and sticky-bucket storage. It may be called in any order relative to the sticky-bucket setters.
Cache freshness window (setCacheMaxAge)
By default the SDK refetches features from the network on every initialize(). Pass setCacheMaxAge(<ms>) to define a freshness window: while the cached features are younger than that window, the network call on the next fetch is skipped and the cache is served as the authoritative result. Once the cache is older, the SDK refetches. This is a staleness gate evaluated on the next fetch, not a background polling mechanism.
var sdkInstance: GrowthBookSDK = GBSDKBuilder(
apiKey = <API_KEY>,
hostURL = <GrowthBook_URL>,
attributes = hashMapOf(),
trackingCallback = { _, _ -> },
networkDispatcher = GBNetworkDispatcherKtor(),
)
.setCacheMaxAge(60 * 60 * 1000)
.initialize()
An explicit refreshCache() call always bypasses this window and hits the network regardless of how fresh the cache is. The window must be positive — setCacheMaxAge throws IllegalArgumentException for zero or negative values (as of 7.9.0; previously such a value was accepted and silently disabled the window).
Not applicable in Remote Evaluation mode. With remoteEval = true the feature cache is bypassed entirely — payloads are evaluated server-side against the current attributes, while the cache is keyed only by API key, so a cached payload could leak a previous user's evaluation to the next one on the same key. Every fetch goes to the network and nothing is persisted, which makes setCacheMaxAge, setStaleTtl and setServeStaleOnError no-ops in that mode.
Stale-while-revalidate (setStaleTtl + setCacheMaxAge)
setStaleTtl(<ms>) turns setCacheMaxAge into a full three-tier stale-while-revalidate policy, where staleTtl is the inner "fresh" window and cacheMaxAge is the outer hard ceiling:
This lets you decouple how often you revalidate from how old data may get before you refuse to serve it:
.setCacheMaxAge(24 * 60 * 60 * 1000)
.setStaleTtl(60 * 60 * 1000)
The hard ceiling (third tier) is armed only when staleTtl is set. Used alone, setCacheMaxAge keeps its original two-tier behaviour: fresh within the window, and beyond it the cache is still served (non-authoritative) while the network revalidates — it is never dropped.
staleTtl must be positive (setStaleTtl throws IllegalArgumentException otherwise) and, when cacheMaxAge is also set, smaller than it — that pairing is enforced at construction, so initialize() throws if it is violated.
By default the expired (third) tier fails closed: past cacheMaxAge nothing stale is served, so an offline device falls back to code defaults. Opt into setServeStaleOnError(true) for HTTP stale-if-error semantics — an expired cache is then served as a last resort only if the revalidating network round fails, so an offline client keeps its (stale) flags instead of losing them:
.setCacheMaxAge(24 * 60 * 60 * 1000)
.setStaleTtl(60 * 60 * 1000)
.setServeStaleOnError(true)
While the network is reachable the freshness ceiling still holds (fresh data is never bypassed); the stale fallback applies purely to network failure. Prefer the default (fail-closed) for kill-switch-style flags that must never be stale.
Observability caveat: when the stale fallback is served, it is applied as a non-authoritative payload and your GBCacheRefreshHandler is not invoked — neither as success nor as failure. The handler's (Boolean, GBError?) contract cannot express "stale fallback served", so signalling either side would mislead. Treat setServeStaleOnError as a best-effort offline safety net, not something you can observe through the refresh handler.
Scope: the fallback covers automatic refreshes (startup, background polling, the stale-while-revalidate round). An explicit refreshCache() does not serve an expired cache — it reports the failure through your refresh handler instead. The freshness ceiling itself holds on every path: nothing older than cacheMaxAge is ever applied, including on refreshCache().
Background polling (setRefreshInterval)
For long-lived processes (JVM/backend) you can opt into periodic background refresh as an alternative to SSE. Configure the interval with setRefreshInterval(<ms>), then start/stop the poller via the SDK:
val sdk = GBSDKBuilder()
.setRefreshInterval(5 * 60 * 1000)
.initialize()
sdk.startPolling()
sdk.stopPolling()
The poller is a coroutine (not a dedicated thread) on the SDK's background scope, so it is cheap while idle, and it retries failed rounds with capped exponential backoff plus random jitter (so many instances that fail together do not all retry in lockstep). Polling and SSE (startAutoRefreshFeatures()) are mutually exclusive — starting SSE stops the poller and startPolling() is a no-op while SSE is active. Stopping SSE with stopAutoRefreshFeatures() releases the slot, so startPolling() works again afterwards.
close() stops the poller too (along with SSE and the background scope), so disposing the SDK instance is enough — you do not need to call stopPolling() first.
Mobile note: the SDK cannot observe app lifecycle, so tie startPolling() / stopPolling() to your foreground/background transitions to avoid keeping the radio awake in the background. On mobile prefer SSE or the pull-on-access cache window (setCacheMaxAge / setStaleTtl); background polling is intended mainly for JVM/backend usage.
Usage
Extensions (GrowthBookExt)
GrowthBookExt is a pure-Kotlin companion module with quality-of-life helpers
over the core SDK — no extra runtime dependencies, all Kotlin Multiplatform
targets. It adds typed feature accessors, fallback strategies, a typed Flag<T>
API, and DSLs for attributes and SDK configuration.
implementation 'io.growthbook.sdk:GrowthBookExt:1.0.0'
Typed feature accessors
Read a feature value with a type and a default instead of unwrapping GBValue:
val theme: String = sdk.getString("theme", default = "light")
val maxItems: Int = sdk.getInt("max-items", default = 10)
val ratio: Double? = sdk.getDoubleOrNull("ratio")
val payload: GBJson? = sdk.getJson("payload")
Each type (String/Boolean/Int/Long/Float/Double) has three variants:
getX(id, default) — value or a constant default
getXOrNull(id) — value or null
getXOrElse(id) { ... } — value or a lazily computed default
Boolean helpers: isEnabled(id), isDisabled(id), and isFeatureKnown(id)
(distinguishes "missing" from "present but off").
Fallback strategies
When a feature is unknown — i.e. absent from the loaded configuration — choose
fail-open vs fail-closed explicitly at the call site:
if (sdk.isEnabled("new-checkout", FallbackStrategy.FAIL_CLOSED)) { ... }
The strategy applies only to an unknown feature. A known-but-off feature still
returns its real evaluated value, and so does a loaded feature whose evaluation
fails (malformed rule, failed prerequisite) — an evaluation error is never mistaken
for a missing feature, so FAIL_OPEN cannot flip a kill switch on.
Startup window. Feature definitions are fetched asynchronously, so until the
first payload (or cached payload) is applied every feature is unknown, and
FAIL_OPEN reports all of them as enabled — permanently so if the fetch fails and
no cache exists. Use suspendFeature, or seed a bundled payload with
initialFeatures, when a flag must not be read before the SDK is ready.
Typed flags — Flag<T>
Declare flags once (key + type + per-feature default) to remove magic strings:
object Flags {
val DARK_MODE = Flag("dark-mode", default = false)
val MAX_ITEMS = Flag("max-items", default = 10)
}
val dark = sdk.isOn(Flags.DARK_MODE)
val items = sdk.value(Flags.MAX_ITEMS)
Flag.default covers both a missing feature and a present-but-wrong-typed value.
Supported types: Boolean/String/Int/Long/Float/Double (decode custom
types via the module instead).
Property delegates
Read a flag as a Kotlin property with by. The flag is re-evaluated on every
read, so the property always reflects the current config — a refreshed payload is
picked up without re-declaring the property:
val newHome by sdk.featureFlag("new-home")
val betaCheckout by sdk.featureFlag("beta-checkout", FallbackStrategy.FAIL_CLOSED)
val maxItems by sdk.featureFlag(Flag("max-items", default = 10))
if (newHome) renderNewHome() else renderOldHome()
Pure sugar over isOn / isEnabled(id, fallback) / value(flag) — same semantics,
just a delegate form. Handy when a flag is read in several places or grouped as
screen/ViewModel config. In a hot loop, snapshot it into a local val to avoid
re-evaluating on each read.
Attributes DSL
Set targeting attributes with plain Kotlin values, hiding the GBValue wrappers:
sdk.setAttributes {
"id" to "user-123"
"premium" to true
"age" to 42
"tags" to listOf("a", "b")
"address" to obj {
"city" to "Kyiv"
}
}
Or build a reusable map: val attrs = buildAttributes { "id" to "user-123" }.
Inside the block, to on a String is the DSL's own entry function and shadows
kotlin.to, so nest objects with obj { } rather than an inline
mapOf("city" to "Kyiv") (a map built outside the block works as a value).
Configuration DSL
Assemble and initialize the SDK declaratively:
val sdk = growthBook {
apiKey = "sdk-abc"
apiHost = "https://cdn.growthbook.io"
networkDispatcher = GBNetworkDispatcherKtor()
enableLogging = true
attributes {
"id" to "user-123"
"premium" to true
}
}
apiKey, apiHost and networkDispatcher are required (missing →
IllegalArgumentException); every other field falls back to the SDK default.
The DSL covers the whole of , so nothing forces you back to the
builder: , , , , ,
, , , ,
, , , ,
, , , and sticky bucketing via either
or (+ optional ).
val sdk = growthBook {
apiKey = "sdk-abc"
apiHost = "https://cdn.growthbook.io"
networkDispatcher = GBNetworkDispatcherKtor()
plugins = listOf(
GrowthBookTrackingPlugin(TrackingPluginConfig(clientKey = "sdk-abc"))
)
cacheMaxAge = 60_000
stickyBucketScope = viewModelScope
}
Models
This SDK operates with such models as GBContext, GBFeature, GBFeatureRule, GBFeatureSource, GBFeatureResult, GBExperiment, GBExperimentResult, etc.
These models can be found in model package. Some entities were put in utils/Constants.kt file. In JS SDK there is only one entity "Result" while in this SDK GBFeatureResult, GBExperimentResult are present.
Attributes
You can specify attributes about the current user and request. These are used for two things:
- Feature targeting (e.g. paid users get one value, free users get another)
- Assigning persistent variations in A/B tests (e.g. user id "123" always gets variation B)
Attributes can be any JSON data type - boolean, integer, float, string, list, or dict.
Proguard (Android)
If you're using ProGuard, you may need to add rules to your configuration file to make it compatible with Obfuscation &
Shriniking tools. These rules are guidelines only and some projects require more to work. You can modify those rules and
adapt them to your project, but be aware that we do not support custom rules.
-keep { *; }
-keep { *; }
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses { *; }
-keepclassmembers {
*** Companion;
}
-keepclasseswithmembers {
kotlinx.serialization.KSerializer serializer(...);
}
Remote Evaluation
This mode brings the security benefits of a backend SDK to the front end by evaluating feature flags exclusively on a
private server. Using Remote Evaluation ensures that any sensitive information within targeting rules or unused feature
variations are never seen by the client. Note that Remote Evaluation should not be used in a backend context.
You must enable Remote Evaluation in your SDK Connection settings. Cloud customers are also required to self-host a
GrowthBook Proxy Server or custom remote evaluation backend.
To use Remote Evaluation, set the remoteEval = true property to your SDK instance. A new evaluation API call will be
made any time a user attribute or other dependency changes — specifically on setAttributes / setAttributesSync /
updateAttributes / updateAttributesSync, setAttributeOverrides, , and .
If you would like to implement Sticky Bucketing while using Remote Evaluation, you must configure your remote evaluation
backend to support Sticky Bucketing. You will not need to provide a StickyBucketService instance to the client side SDK.
Contextual Bandits
A contextual bandit splits an experiment's audience into contexts (leaves) and gives each leaf its own variation
weights, so traffic is allocated per segment instead of globally. The weight maths (Thompson sampling) runs
server-side — the SDK neither learns nor updates anything. At evaluation time it simply picks the first leaf whose
condition matches the user's attributes and buckets by that leaf's weights, using the ordinary experiment machinery.
Nothing needs to be enabled in code. Bandit definitions arrive in the features payload (plain or encrypted, alongside
features and savedGroups), and a bandit-driven rule is evaluated like any other experiment rule.
What is new is the exposure metadata on GBExperimentResult, which lets your warehouse attribute an exposure to the
exact leaf and weight generation that produced it:
val sdkInstance = GBSDKBuilder(
apiKey = <API_KEY>,
hostURL = <GrowthBook_URL>,
attributes = mapOf("id" to GBString("user-123"), "country" to GBString("UA")),
trackingCallback = { experiment, result ->
analytics.track(
event = "experiment_viewed",
experimentId = experiment.key,
variationId = result.variationId,
leafId = result.leafId,
variationWeights = result.variationWeights,
banditVersion = result.banditVersion,
)
},
networkDispatcher = GBNetworkDispatcherKtor(),
).initialize()
The three fields are populated only for users actually enrolled in a bandit experiment; they are null for ordinary
experiments and for users the rule excluded. A leafId of -1 means no leaf condition matched and the rule's aggregate
weights were used instead.
Sticky bucketing works with bandit rules as it does with any experiment rule. One caveat for training pipelines: a
sticky-bucketed user keeps their stored variation, but the exposure reports the current leaf's variationWeights —
which may differ from the weights in force when they were originally bucketed. Check result.stickyBucketUsed before
treating variationWeights as the assignment propensities. For offline-first setups, seed the
definitions with setInitialPayload — does not carry
them.
Note: GrowthBook's querystring-based variation override (?experiment-key=0) is not implemented in this SDK, so it
does not apply to bandit rules either. Use setForcedVariations for the same effect.
Sticky Bucketing
By default, GrowthBook does not persist assigned experiment variations for a user.
We rely on deterministic hashing to ensure that the same user attributes always map to the same experiment variation.
However, there are cases where this isn't good enough. For example, if you change targeting conditions
in the middle of an experiment, users may stop being shown a variation even if they were previously bucketed into it.
Sticky Bucketing is a solution to these issues. You can provide a Sticky Bucket Service to the GrowthBook instance
to persist previously seen variations and ensure that the user experience remains consistent for your users.
Sticky bucketing ensures that users see the same experiment variant, even when user session, user login status, or
experiment parameters change. See the Sticky Bucketing docs for more
information. If your organization and experiment supports sticky bucketing, you can implement an instance of
the StickyBucketService to use Sticky Bucketing. For simple bucket persistence using the CachingLayer.
Sticky Bucket documents contain three fields:
- attributeName - The name of the attribute used to identify the user (e.g. id, cookie_id, etc.)
- attributeValue - The value of the attribute (e.g. 123)
- assignments - A dictionary of persisted experiment assignments. For example: {"exp1__0":"control"}
The attributeName/attributeValue combo is the primary key.
Here's an example implementation using a theoretical db object:
License
This project uses the MIT license. The core GrowthBook app will always remain open and free, although we may add some
commercial enterprise add-ons in the future.