Client Persistent Assignment
Persistent assignment allows you to ensure that a user's variant stays consistent while an experiment is running, regardless of changes to allocation or targeting.
Persistent Storage
Persistent storage takes an "adapter" approach, allowing you to plug in a storage solution of your choice to store assignments, which can then be referenced later to ensure a user stays in the same bucket. You can implement an adapter that uses Local Storage, or one that uses remote storage, to enable persistence across multiple devices.
The user persistent storage interface consists of just a load
/loadAsync
, save
, delete
API for read/write operations.
Persistent Storage is currently supported on:
- The modern
Javascript,
React
, andReact Native
SDKs, including on-device evaluation Android, on-device evaluation
onlyiOS, on-device evaluation
only- See below for more information on Android and iOS Support
Persistent Storage Logic
- Providing a storage adapter on Statsig initialization will give the SDK access to read & write on your custom storage
- Providing user persisted values to
get_experiment
will inform the SDK to- save the evaluation of the current user on first evaluation
- load the previously saved evaluation of a persisted user on subsequent evaluations
- delete the previously saved evaluation of a persisted user if the experiment is no longer active
- Not providing user persisted values to
get_experiment
will delete a previously saved evaluation
Example usage
- Javascript
- React
- JS On-Device Eval
- Android On-Device Eval
- JS (legacy)
import { StatsigClient } from '@statsig/js-client';
import { UserPersistentOverrideAdapter } from '@statsig/js-user-persisted-storage';
// Custom storage implementation using localStorage
class LocalStorageUserPersistedStorage {
load(key) {
return JSON.parse(localStorage.getItem(key) ?? '{}');
}
save(key, experiment, data) {
const values = JSON.parse(localStorage.getItem(key) ?? '{}');
values[experiment] = JSON.parse(data);
localStorage.setItem(key, JSON.stringify(values));
}
delete(key, experiment) {
const data = JSON.parse(localStorage.getItem(key) ?? '{}');
delete data[experiment];
localStorage.setItem(key, JSON.stringify(data));
}
}
const storage = new LocalStorageUserPersistedStorage();
const adapter = new UserPersistentOverrideAdapter(storage);
const client = new StatsigClient('client-key', { overrideAdapter: adapter });
await client.initializeAsync({ userID: "123" });
const user = { userID: "123" };
const userPersistedValues = adapter.loadUserPersistedValues(user, 'userID');
const experiment = client.getExperiment('active_experiment', { userPersistedValues });
console.log(experiment.getGroupName()); // 'Control'
// Switch to different user - will maintain same experiment group due to persistence
const newUser = { userID: "456" };
const newExperiment = client.getExperiment('active_experiment', { userPersistedValues });
console.log(newExperiment.getGroupName()); // Still 'Control'
The Syntax for react matches vanilla Javascript - for a full implementation example, you can refer to our Persistent Storage Example in Next.js.
Synchronous Persistent Evaluations
The UserPersistentStorageInterface
exposes two methods for synchronous persistent storage, which will be called by default when evaluating an experiment.
interface UserPersistentStorageInterface {
suspend fun load(key: String): PersistedValues
fun save(key: String, experimentName: String, data: String)
fun delete(key: String, experiment: String)
...
}
The key
string is a combination of ID and ID Type: e.g. "123:userID" or "abc:stableID" which the SDK will construct and call get
and set
on by default
You can use this interface to persist evaluations synchronously to local storage. If you need an async interface, read on.
Asynchronous Persistent Evaluations
The UserPersistentStorageInterface
exposes two methods for asynchronous persistent evaluations. Because the getExperiment
call is synchronous, you must load the value first, and pass it in as userPersistedValues
interface UserPersistentStorageInterface {
fun loadAsync(key: String, callback: IPersistentStorageCallback)
fun save(key: String, experimentName: String, data: String)
fun delete(key: String, experiment: String)
...
}
interface IPersistentStorageCallback {
fun onLoaded(values: PersistedValues)
}
For your convenience, we've created a top level method to load the value for a given user and ID Type:
// Asynchronous load values
val userPersistedValues = Statsig.client.loadUserPersistedValuesAsync(
user: StatsigUser,
idType: string, // userID, stableID, customIDxyz, etc
callback: IPersistentStorageCallback
);
// Synchronous load values
val userPersistedvalues = Statsig.client.loadUserPersistedValues(
user: StatsigUser,
idType: string, // userID, stableID, customIDxyz, etc
)
Putting it all together, assuming you have implemented the UserPersistentStorageInterface
and set it on StatsigOptions
, your call site will look like this:
// Asynchronous
val callback = object: IPersistentStorageCallback {
@override
fun onLoaded(values: PersistedValues) {
Statsig.getExperiment(user, "sample_experiment", GetExperimentOptions(userPersistedValues = values))
}
}
val userValues = Statsig.client.loadUserPersistedValuesAsync(user, "userID", callback)
// Synchronous
val user = StatsigUser(userID = "user123")
val userValues = Statsig.client.loadUserPersistedValues(user, 'userID');
const experiment = statsig.getExperiment({userID: "123"}, 'the_allocated_experiment', { userPersistedValues: userValues });
If you are using java, you can only override loadAsync function and ignore load function as empty.
const storage = new CustomStorageAdapter(); // Need to implement
const adapter = new UserPersistentOverrideAdapter(storage);
const client = new StatsigOnDeviceEvalClient(
'client-key',
{ overrideAdapter: adapter }
);
await client.initializeAsync();
const userInControl = { userID: "123" }
const userInUnknown = { userID: "unknown" }
const userPersistedValues = adapter.loadUserPersistedValues(user, 'userID');
let experiment = client.getExperiment('active_experiment', user, { userPersistedValues });
console.log(experiment.getGroupName()) // 'Control'
experiment = client.getExperiment('active_experiment', userInUnknown, { userPersistedValues });
console.log(experiment.getGroupName()) // 'Control'
For a full implementation example, you can refer to our Persistent Storage On-Device Eval Example in Next.js.
await statsig.initialize(
'client-key',
{ userPersistentStorage: new CustomStorageAdapter() } // Need to implement
);
const userInControl = { userID: "123" }
const userInUnknown = { userID: "unknown" }
const userPersistedValues = await statsig.loadUserPersistedValuesAsync(userInControl, 'userID');
let experiment = statsig.getExperiment(userInControl, 'active_experiment', { userPersistedValues });
console.log(experiment.getGroupName()) // 'Control'
experiment = statsig.getExperiment(userInUnknown, 'active_experiment', { userPersistedValues });
console.log(experiment.getGroupName()) // 'Control'
Support in iOS and Android SDKs
Android and iOS SDKs offer a simplified version of Persistent Storage called keepDeviceValues
that relies on on-device storage. While this is less flexible its simple to leverage - with a simple boolean flag. When enabled, the SDK (under the hood in the getExperiment/getLayer call) will check for previous stored value, and use those instead, even if allocation or targeting has changed. When the experiment ends - the SDK will stop persisting values.
- iOS
- Android
Swift:
// With an Experiment:
let titleExperiment = Statsig.getExperiment("new_user_promo_title", true) // <-- "true" flag sets keep device values
// Use the experiment like normal:
let promoTitle = titleExperiment.getValue(forKey: "title", defaultValue: "Welcome to Statsig!")
// Or a Layer:
let layer = Statsig.getLayer("user_promo_experiments", true) // <-- "true" flag sets keep device values
// Use the layer like normal:
let promoTitle = layer.getValue(forKey: "title", defaultValue: "Welcome to Statsig!")
Objective C:
// With an Experiment:
DynamicConfig *expConfig = [Statsig getExperimentForName:@"new_user_promo_title" true]; // <-- "true" flag sets keep device values
// Use the experiment like normal:
NSString *promoTitle = [expConfig getStringForKey:@"title" defaultValue:@"Welcome to Statsig! Use discount code WELCOME10OFF for 10% off your first purchase!"];
double discount = [expConfig getDoubleForKey:@"discount" defaultValue:0.1];
double price = msrp * (1 - discount);
Java:
// With an Experiment:
DynamicConfig titleExperiment = Statsig.getExperiment("new_user_promo_title", true); // <-- "true" flag sets keep device values
// Use the experiment like normal:
String promoTitle = titleExperiment.getString("title", "Welcome to Statsig!");
// Or a Layer:
Layer layer = Statsig.getLayer("user_promo_experiments", true) // <-- "true" flag sets keep device values
// Use the layer like normal:
String promoTitle = layer.getString("title", "Welcome to Statsig!");
Kotlin:
// With an Experiment:
val titleExperiment = Statsig.getExperiment("new_user_promo_title", true) // <-- "true" flag sets keep device values
// Use the experiment like normal:
val promoTitle = titleExperiment.getString("title", "Welcome to Statsig!")
// Or a Layer:
val layer = Statsig.getLayer("user_promo_experiments", true) // <-- "true" flag sets keep device values
// Use the layer like normal:
val promoTitle = layer.getString("title", "Welcome to Statsig!")