Skip to content

Java SDK Reference

TL;DR: Add io.featureflip:featureflip-java, call client.boolVariation("flag-key", context, false), and you’re evaluating flags — thread-safe and non-blocking.

The io.featureflip:featureflip-java library provides a thread-safe client for evaluating feature flags in Java applications. The SDK targets Java 11+ via Gradle toolchains, so it runs on every modern JVM — including LTS releases (11, 17, 21) and recent non-LTS builds — with no module-system gymnastics. Distribution is Maven Central under io.featureflip:featureflip-java.

Internally, the client uses OkHttp for HTTP and SSE streaming and Jackson for JSON serialization. Both are well-known, battle-tested libraries that integrate cleanly with Spring, Micronaut, Quarkus, and Dropwizard. The streaming connection runs on a dedicated background thread; if it drops, the SDK falls back to polling every 30 seconds while continuing to serve the last known flag values.

The client is built once per process via FeatureflipClient.builder().sdkKey(...).build() and shared across threads. All variation methods (boolVariation, stringVariation, intVariation, doubleVariation, jsonVariation) read from a ConcurrentHashMap and return without blocking, so you can call them from request handlers, schedulers, or message consumers without coordinating locks. The matching client implementation also powers the Android SDK, which adds Kotlin coroutines and lifecycle integration on top.

View source on GitHub

dependencies {
implementation("io.featureflip:featureflip-java:2.13.0")
}
dependencies {
implementation 'io.featureflip:featureflip-java:2.13.0'
}
<dependency>
<groupId>io.featureflip</groupId>
<artifactId>featureflip-java</artifactId>
<version>2.13.0</version>
</dependency>

The Featureflip Java SDK is designed to be used as a singleton across your application. Obtain the client via the static factory:

FeatureflipClient client = FeatureflipClient.get("your-sdk-key");

Or with custom configuration:

FeatureFlagConfig config = FeatureFlagConfig.builder()
.baseUrl("https://eval.featureflip.io")
.streaming(true)
.build();
FeatureflipClient client = FeatureflipClient.get("your-sdk-key", config);

Calling get() (or builder().build()) multiple times with the same SDK key returns handles that share a single underlying client — the SDK maintains a process-wide cache keyed by SDK key. You cannot accidentally open duplicate streaming connections by constructing multiple clients.

If you’re using a DI container like Spring Boot or Micronaut, register the client as a singleton bean:

@Configuration
public class FeatureflipConfig {
@Bean
public FeatureflipClient featureflipClient(@Value("${featureflip.sdk-key}") String sdkKey) {
return FeatureflipClient.get(sdkKey);
}
}

Even if you hand-roll a prototype-scoped or request-scoped registration, the factory’s deduplication ensures all resolves share one underlying client — the misregistration is neutralized, not catastrophic.

close() is refcounted: when multiple handles share one cached client, closing one handle does not shut down the shared background tasks. The real shutdown runs only when the last handle is closed. In a typical application this means the client lives for the entire process lifetime and is cleaned up automatically at shutdown via a try-with-resources block in main or via the DI container’s shutdown lifecycle.

The FeatureFlagConfig class is built via a builder with the following options:

OptionTypeDefaultDescription
baseUrlString"https://eval.featureflip.io"Base URL of the Evaluation API
connectTimeoutDuration5sTimeout for establishing connections
readTimeoutDuration10sTimeout for reading responses
streamingbooleantrueEnable SSE for real-time flag updates
pollIntervalDuration30sPolling interval (used when streaming is disabled)
flushIntervalDuration30sInterval between event flush batches
flushBatchSizeint100Maximum number of events per flush batch
initTimeoutDuration10sMaximum time to wait for initialization
snapshotStoreSnapshotStorenonePersists the last flag configuration so a new process can start from it when the API is unreachable. See Starting during an outage
maxSnapshotAgeSecondsdouble86400Oldest snapshot, in seconds, that a client will start from. Must be positive and finite
wrapperString, StringnoneFor libraries that wrap the SDK: a name and version sent after the SDK’s own User-Agent token

Configuration is applied through the client builder rather than constructed directly.

Obtain a client via the static factory (recommended):

FeatureflipClient client = FeatureflipClient.get("sdk-dev-abc123");

Or with configuration via the builder:

FeatureflipClient client = FeatureflipClient.get("sdk-dev-abc123",
FeatureFlagConfig.builder()
.baseUrl("https://eval.featureflip.io")
.streaming(true)
.initTimeout(Duration.ofSeconds(30))
.build());

Both forms perform the initial flag fetch and start background streaming or polling. If the initial fetch fails, initialization continues in the background.

Blocks until the client has loaded flag data. Throws FeatureFlagInitializationException if the timeout expires. With a snapshotStore configured it returns instead of throwing, see Starting during an outage.

client.waitForInitialization();
boolean ready = client.isInitialized();

Returns true once the client has successfully loaded flag data (or, with a snapshotStore configured, finished starting, see Starting during an outage), and false after close() — a closed handle serves the caller’s default from every accessor.

MethodParameterDescription
baseUrl()StringSet the Evaluation API base URL
connectTimeout()DurationSet the connection timeout
readTimeout()DurationSet the read timeout
streaming()booleanEnable or disable SSE streaming
pollInterval()DurationSet the polling interval
flushInterval()DurationSet the event flush interval
flushBatchSize()intSet the maximum events per batch
initTimeout()DurationSet the initialization timeout
snapshotStore()SnapshotStoreOpt in to starting from a stored snapshot
maxSnapshotAgeSeconds()doubleSet the oldest usable snapshot, in seconds
wrapper()String, StringName a library that wraps the SDK (version may be null)
build()—Build the client and start initialization

Without a snapshot store, a client that starts while the Evaluation API is unreachable serves every default until the API returns, and waitForInitialization() throws FeatureFlagInitializationException when initTimeout passes. Set snapshotStore to let a new process start from the last configuration it saved instead. The option is off unless you set it. For how this fits with the rest of the failure behavior, see Reliability.

FeatureflipClient client = FeatureflipClient.get("sdk-dev-abc123",
FeatureFlagConfig.builder()
.snapshotStore(new FileSnapshotStore("/var/lib/featureflip/snapshot.json"))
.maxSnapshotAgeSeconds(6 * 60 * 60)
.build());

How it behaves:

  • The snapshot is read only when the initial fetch fails or times out. A successful fetch always wins, and the snapshot is never consulted afterward.
  • When the fetch fails and no usable snapshot exists, the client still counts as initialized and serves your defaults, and waitForInitialization() returns instead of throwing. startupInfo() says why. If you relied on catching FeatureFlagInitializationException to fail fast, check client.startupInfo().getSource() instead, because with a snapshot store configured it no longer throws.
  • After a start from a snapshot the client keeps retrying the live API and replaces the snapshot’s values as soon as it connects. Listeners already registered at that moment fire for what changed between the two. FeatureflipClient.get() blocks while it starts, so a listener you add right after it returns can miss that first change. Read startupInfo() after registering if you need to know.
  • The client writes the full configuration after each full update it applies (the first fetch, a poll, a stream resync). Once it is connected live, single-flag updates write it too. Writes run on a background thread and never block evaluation.
  • While the client stays connected and healthy, it refreshes the stored copy at most every 5 minutes even when nothing changed, so a quiet configuration does not age past maxSnapshotAgeSeconds. Changes are written right away.
  • The fetch is bounded by initTimeout, and so is the store read. If the fetch times out and then the store read hangs too, the client finishes starting after twice initTimeout, serving defaults.
  • The option is read on the first client built for an SDK key. Later calls with the same key reuse the existing one, as described under Lifetime, and log a warning if they pass a different snapshotStore instance or maxSnapshotAgeSeconds.

A snapshot contains your targeting rules, including any customer attribute values used in them. On POSIX systems FileSnapshotStore creates the file readable by its owner only (mode 0600). No fsync runs before the file is replaced. A power loss can therefore leave an empty file, which the client reads as snapshot-invalid, logging a warning and serving defaults until the next write.

Container disks are usually ephemeral. A file written inside the image or container layer is gone after a redeploy, so point the path at a mounted volume:

docker-compose.yml
services:
api:
volumes:
- featureflip-snapshot:/var/lib/featureflip
volumes:
featureflip-snapshot:

On Kubernetes, mount a PersistentVolumeClaim, or an emptyDir if you only need the file to survive container restarts within a pod, at the same path.

FileSnapshotStore won’t create the parent directory, so it must already exist. Check the owner too. Docker creates a new named volume owned by root unless the image already has that directory, so a process running as a non-root user fails every write and logs a warning each time. Create the directory in your Dockerfile and hand it to that user, for example RUN mkdir -p /var/lib/featureflip && chown app /var/lib/featureflip.

client.startupInfo() reports where the flag data came from. It returns an immutable StartupInfo:

MethodValuesMeaning
getSource()NONE, SNAPSHOT, LIVENONE: no data yet, so evaluations return your defaults. SNAPSHOT: the client started from the store. LIVE: the initial fetch succeeded, or the client has since connected after starting from SNAPSHOT or NONE
getReason()"snapshot", "snapshot-expired", "snapshot-invalid", or nullSet when a snapshot was considered, and cleared once the client is live. snapshot: it was used. snapshot-expired: it was older than maxSnapshotAgeSeconds. snapshot-invalid: the stored text could not be used. That covers a garbage, empty or cut-off file, a snapshot written for a different SDK key or base URL, and a timestamp more than 60 seconds in the future. An empty store sets no reason
getSnapshotAgeSeconds()Long or nullAge in whole seconds of the snapshot that was used or rejected for age
StartupInfo info = client.startupInfo();
if (info.getSource() != StartupInfo.Source.LIVE) {
log.warn("flags did not load from the API: source={} reason={} age={}",
info.getSource(), info.getReason(), info.getSnapshotAgeSeconds());
}

To share one snapshot across processes, implement SnapshotStore. It stores text: get() returns the string last stored, or null when there is none, and put(text) replaces it. Both are synchronous and may be called from a background thread. This sketch uses Redis:

import io.featureflip.client.SnapshotStore;
import redis.clients.jedis.JedisPooled;
public final class RedisSnapshotStore implements SnapshotStore {
private static final String KEY = "featureflip:snapshot:production";
private final JedisPooled redis;
public RedisSnapshotStore(JedisPooled redis) {
this.redis = redis;
}
@Override
public String get() {
return redis.get(KEY);
}
@Override
public void put(String text) {
redis.set(KEY, text);
}
}

The store never needs to parse the text. The SDK wraps the configuration in its own envelope, with a timestamp and a hash of the SDK key and base URL, and checks all of it on read. Text written for a different environment is rejected as snapshot-invalid and never served, which is why one store instance can safely be shared by clients for several SDK keys. Still give each environment its own key or path. Two environments sharing one slot overwrite each other’s snapshot.

A store that throws counts as having no snapshot on read and as a dropped write on put. Either way the client logs a warning.

All evaluation methods accept a flag key, an evaluation context, and a default value. If the flag is not found or an error occurs, the default value is returned.

EvaluationContext context = EvaluationContext.builder("u-123").build();
boolean enabled = client.boolVariation("dark-mode", context, false);

Signature:

boolean boolVariation(String key, EvaluationContext context, boolean defaultValue)
String plan = client.stringVariation("pricing-tier", context, "free");

Signature:

String stringVariation(String key, EvaluationContext context, String defaultValue)
int limit = client.intVariation("rate-limit", context, 100);

Signature:

int intVariation(String key, EvaluationContext context, int defaultValue)
double ratio = client.doubleVariation("rollout-ratio", context, 0.5);

Signature:

double doubleVariation(String key, EvaluationContext context, double defaultValue)

Deserializes a JSON flag value to the specified class using Jackson:

BannerConfig banner = client.jsonVariation(
"banner-config",
context,
new BannerConfig("Welcome", "#000"),
BannerConfig.class
);

Signature:

<T> T jsonVariation(String key, EvaluationContext context, T defaultValue, Class<T> type)

Detail methods return an EvaluationDetail<T> object with the value and metadata about the decision.

EvaluationDetail<Boolean> detail = client.boolVariationDetail("dark-mode", context, false);
System.out.println(detail.getValue()); // true
System.out.println(detail.getReason()); // RULE_MATCH
System.out.println(detail.getRuleId()); // "rule-abc"
System.out.println(detail.getErrorMessage()); // null
EvaluationDetail<String> detail = client.stringVariationDetail("pricing-tier", context, "free");
EvaluationDetail<Integer> detail = client.intVariationDetail("rate-limit", context, 100);
EvaluationDetail<Double> detail = client.doubleVariationDetail("rollout-ratio", context, 0.5);
MethodReturn typeDescription
getValue()TThe evaluated flag value
getReason()EvaluationReasonWhy this value was returned
getRuleId()StringThe ID of the matched targeting rule (may be null)
getErrorMessage()StringError message if evaluation failed (may be null)
ValueDescription
RULE_MATCHA targeting rule matched the context
FALLTHROUGHNo rules matched; the fallthrough variation was served
FLAG_DISABLEDThe flag is disabled; the off variation was served
FLAG_NOT_FOUNDThe flag key does not exist
ERRORAn error occurred during evaluation

The EvaluationContext class uses a builder pattern with a required user ID. Custom attributes feed into targeting rules, and the user ID anchors percentage rollouts so each user gets a stable bucket assignment:

EvaluationContext context = EvaluationContext.builder("u-123")
.set("email", "user@example.com")
.set("country", "US")
.set("plan", "pro")
.build();
MethodParameterDescription
builder()String userIdCreate a builder with the required user ID
set()String key, Object valueAdd a custom attribute (keys are case-sensitive)
build()—Build the immutable context
String userId = context.getUserId(); // "u-123"
Object plan = context.getAttribute("plan"); // "pro"

getAttribute() checks custom attributes first, then falls back to built-in properties (userId, user_id).

Records a custom analytics event.

Map<String, Object> metadata = Map.of(
"plan", "pro",
"amount", 29.99
);
client.track("purchase-completed", context, metadata);

Signature:

void track(String eventName, EvaluationContext context, Map<String, Object> metadata)

Events are batched and flushed automatically based on flushInterval and flushBatchSize.

Featureflip counts each event name, per environment. The metadata you pass is transmitted with the event but is not stored, and event counts are not surfaced in the app or API.

Records an Identify event for the given user. The context’s custom attributes are sent as the event’s metadata but are not stored. The event is counted per environment.

This does not affect flag evaluation. Targeting rules and segments are matched against the context you pass to each variation call. identify() does not set a context for subsequent calls, and it does not persist user attributes for later targeting.

client.identify(EvaluationContext.builder("123")
.set("email", "user@example.com")
.set("plan", "pro")
.build());

Signature:

void identify(EvaluationContext context)

Forces all buffered events to be sent to the server immediately.

client.flush();

Subscribes to flag-configuration updates. The listener gets the keys of the flags each change touched, batched into one call however many flags that was. Needs SDK 2.9.0 or newer.

Runnable unsubscribe = client.onUpdate(keys -> {
log.info("flags changed: {}", keys);
cache.invalidateAll(keys);
});
// later, when you no longer need it
unsubscribe.run();

A flag is reported when it is added, removed or modified. Two more cases count as well, because each one moves a flag’s evaluated value without touching its own configuration: an edit to a segment that the flag’s targeting rules reference, and a change to a flag it depends on through a prerequisite.

The initial flag load doesn’t fire the listener. The list it receives is never null or empty, and it comes sorted and unmodifiable.

Listeners run on the SDK’s streaming or polling thread, so keep them short. A slow listener holds up flag delivery, and on the streaming path it can stall the connection. Hand anything heavier to your own executor.

An exception thrown by a listener is caught and logged. It doesn’t stop flag delivery or the other listeners.

The returned Runnable unsubscribes, and running it twice is harmless. Closing the client handle that registered a listener drops it too, so a handle that goes away can’t leave a listener firing on the shared client. Passing null, or subscribing on a closed handle, returns a Runnable that does nothing.

Signature:

@FunctionalInterface
public interface FlagUpdateListener {
void onUpdate(List<String> flagKeys);
}
public Runnable onUpdate(FlagUpdateListener listener)

The client implements AutoCloseable. Use try-with-resources for automatic cleanup:

try (FeatureflipClient client = FeatureflipClient.get("sdk-key")) {
boolean enabled = client.boolVariation("dark-mode", context, false);
}
// client.close() is called automatically

Stops SSE streaming or polling, flushes remaining events, and shuts down the executor service (with a 5-second graceful timeout). Safe to call multiple times.

client.close();

Creates a test client with fixed flag values. No network calls are made and no background threads are started.

FeatureflipClient client = FeatureflipClient.forTesting(Map.of(
"dark-mode", true,
"pricing-tier", "enterprise",
"rate-limit", 500
));
EvaluationContext ctx = EvaluationContext.builder("test-user").build();
client.boolVariation("dark-mode", ctx, false); // true
client.stringVariation("pricing-tier", ctx, "free"); // "enterprise"

Signature:

static FeatureflipClient forTesting(Map<String, Object> values)

The test client is fully initialized immediately. Flag values are returned regardless of the evaluation context. The values map is copied and immutable.

Thrown by waitForInitialization() when the client cannot load flag data within the configured timeout. Also thrown if initialization is interrupted.

try {
client.waitForInitialization();
} catch (FeatureFlagInitializationException e) {
// Handle initialization failure
log.error("Failed to initialize: {}", e.getMessage());
}

The SDK uses the following runtime dependencies:

LibraryPurpose
OkHttp 4.xHTTP client and SSE
Jackson 2.xJSON serialization/deserialization
SLF4J 2.xLogging facade