Java SDK Reference
TL;DR: Add
io.featureflip:featureflip-java, callclient.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.
Installation
Section titled “Installation”Gradle (Kotlin DSL)
Section titled “Gradle (Kotlin DSL)”dependencies { implementation("io.featureflip:featureflip-java:2.13.0")}Gradle (Groovy)
Section titled “Gradle (Groovy)”dependencies { implementation 'io.featureflip:featureflip-java:2.13.0'}<dependency> <groupId>io.featureflip</groupId> <artifactId>featureflip-java</artifactId> <version>2.13.0</version></dependency>Lifetime
Section titled “Lifetime”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:
@Configurationpublic 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.
Closing
Section titled “Closing”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.
Configuration
Section titled “Configuration”The FeatureFlagConfig class is built via a builder with the following options:
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | String | "https://eval.featureflip.io" | Base URL of the Evaluation API |
connectTimeout | Duration | 5s | Timeout for establishing connections |
readTimeout | Duration | 10s | Timeout for reading responses |
streaming | boolean | true | Enable SSE for real-time flag updates |
pollInterval | Duration | 30s | Polling interval (used when streaming is disabled) |
flushInterval | Duration | 30s | Interval between event flush batches |
flushBatchSize | int | 100 | Maximum number of events per flush batch |
initTimeout | Duration | 10s | Maximum time to wait for initialization |
snapshotStore | SnapshotStore | none | Persists the last flag configuration so a new process can start from it when the API is unreachable. See Starting during an outage |
maxSnapshotAgeSeconds | double | 86400 | Oldest snapshot, in seconds, that a client will start from. Must be positive and finite |
wrapper | String, String | none | For 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.
Initialization
Section titled “Initialization”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.
waitForInitialization()
Section titled “waitForInitialization()”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();isInitialized()
Section titled “isInitialized()”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.
Builder methods
Section titled “Builder methods”| Method | Parameter | Description |
|---|---|---|
baseUrl() | String | Set the Evaluation API base URL |
connectTimeout() | Duration | Set the connection timeout |
readTimeout() | Duration | Set the read timeout |
streaming() | boolean | Enable or disable SSE streaming |
pollInterval() | Duration | Set the polling interval |
flushInterval() | Duration | Set the event flush interval |
flushBatchSize() | int | Set the maximum events per batch |
initTimeout() | Duration | Set the initialization timeout |
snapshotStore() | SnapshotStore | Opt in to starting from a stored snapshot |
maxSnapshotAgeSeconds() | double | Set the oldest usable snapshot, in seconds |
wrapper() | String, String | Name a library that wraps the SDK (version may be null) |
build() | — | Build the client and start initialization |
Starting during an outage
Section titled “Starting during an outage”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 catchingFeatureFlagInitializationExceptionto fail fast, checkclient.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. ReadstartupInfo()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 twiceinitTimeout, 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
snapshotStoreinstance ormaxSnapshotAgeSeconds.
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:
services: api: volumes: - featureflip-snapshot:/var/lib/featureflipvolumes: 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.
startupInfo()
Section titled “startupInfo()”client.startupInfo() reports where the flag data came from. It returns an immutable StartupInfo:
| Method | Values | Meaning |
|---|---|---|
getSource() | NONE, SNAPSHOT, LIVE | NONE: 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 null | Set 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 null | Age 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());}Custom stores
Section titled “Custom stores”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.
Evaluating Flags
Section titled “Evaluating Flags”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.
boolVariation()
Section titled “boolVariation()”EvaluationContext context = EvaluationContext.builder("u-123").build();boolean enabled = client.boolVariation("dark-mode", context, false);Signature:
boolean boolVariation(String key, EvaluationContext context, boolean defaultValue)stringVariation()
Section titled “stringVariation()”String plan = client.stringVariation("pricing-tier", context, "free");Signature:
String stringVariation(String key, EvaluationContext context, String defaultValue)intVariation()
Section titled “intVariation()”int limit = client.intVariation("rate-limit", context, 100);Signature:
int intVariation(String key, EvaluationContext context, int defaultValue)doubleVariation()
Section titled “doubleVariation()”double ratio = client.doubleVariation("rollout-ratio", context, 0.5);Signature:
double doubleVariation(String key, EvaluationContext context, double defaultValue)jsonVariation()
Section titled “jsonVariation()”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)Evaluation Details
Section titled “Evaluation Details”Detail methods return an EvaluationDetail<T> object with the value and metadata about the decision.
boolVariationDetail()
Section titled “boolVariationDetail()”EvaluationDetail<Boolean> detail = client.boolVariationDetail("dark-mode", context, false);
System.out.println(detail.getValue()); // trueSystem.out.println(detail.getReason()); // RULE_MATCHSystem.out.println(detail.getRuleId()); // "rule-abc"System.out.println(detail.getErrorMessage()); // nullstringVariationDetail()
Section titled “stringVariationDetail()”EvaluationDetail<String> detail = client.stringVariationDetail("pricing-tier", context, "free");intVariationDetail()
Section titled “intVariationDetail()”EvaluationDetail<Integer> detail = client.intVariationDetail("rate-limit", context, 100);doubleVariationDetail()
Section titled “doubleVariationDetail()”EvaluationDetail<Double> detail = client.doubleVariationDetail("rollout-ratio", context, 0.5);EvaluationDetail<T>
Section titled “EvaluationDetail<T>”| Method | Return type | Description |
|---|---|---|
getValue() | T | The evaluated flag value |
getReason() | EvaluationReason | Why this value was returned |
getRuleId() | String | The ID of the matched targeting rule (may be null) |
getErrorMessage() | String | Error message if evaluation failed (may be null) |
EvaluationReason
Section titled “EvaluationReason”| Value | Description |
|---|---|
RULE_MATCH | A targeting rule matched the context |
FALLTHROUGH | No rules matched; the fallthrough variation was served |
FLAG_DISABLED | The flag is disabled; the off variation was served |
FLAG_NOT_FOUND | The flag key does not exist |
ERROR | An error occurred during evaluation |
Evaluation Context
Section titled “Evaluation Context”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();Builder methods
Section titled “Builder methods”| Method | Parameter | Description |
|---|---|---|
builder() | String userId | Create a builder with the required user ID |
set() | String key, Object value | Add a custom attribute (keys are case-sensitive) |
build() | — | Build the immutable context |
Accessing attributes
Section titled “Accessing attributes”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).
Event Tracking
Section titled “Event Tracking”track()
Section titled “track()”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.
identify()
Section titled “identify()”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)flush()
Section titled “flush()”Forces all buffered events to be sent to the server immediately.
client.flush();Flag Change Notifications
Section titled “Flag Change Notifications”onUpdate()
Section titled “onUpdate()”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 itunsubscribe.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:
@FunctionalInterfacepublic interface FlagUpdateListener { void onUpdate(List<String> flagKeys);}
public Runnable onUpdate(FlagUpdateListener listener)Cleanup
Section titled “Cleanup”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 automaticallyclose()
Section titled “close()”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();Testing
Section titled “Testing”FeatureflipClient.forTesting()
Section titled “FeatureflipClient.forTesting()”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); // trueclient.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.
FeatureFlagInitializationException
Section titled “FeatureFlagInitializationException”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());}Dependencies
Section titled “Dependencies”The SDK uses the following runtime dependencies:
| Library | Purpose |
|---|---|
| OkHttp 4.x | HTTP client and SSE |
| Jackson 2.x | JSON serialization/deserialization |
| SLF4J 2.x | Logging facade |
See also
Section titled “See also”- Java Quickstart — get started in under 5 minutes
- Targeting & Segments — control which users see which variation
- Rollout Strategies — gradual percentage-based rollouts
- Evaluation API — REST API reference
- SDK Overview — compare server-side and client-side SDKs