featurefly
v0.4.0
Published
Lightweight, framework-agnostic Feature Flags SDK with built-in caching, retry, circuit breaker, targeting, A/B testing, and real-time streaming
Maintainers
Readme
FeatureFly 🚀
Lightweight, universal Feature Flags SDK for Node.js and the browser. One package. Backend and frontend. Zero config to start.
📑 Table of Contents
- Installation
- Quick Start
- Usage by Environment
- Configuration
- API Reference
- Considerations
- Evaluation Flow
- Resilience
- Feature Comparison
- Changelog
- Upgrading to 0.4.0
- Roadmap
- Contributing
- License
📦 Installation
npm install featureflyZero runtime dependencies! FeatureFly has no runtime dependencies. Only install your framework if you want to use the framework-specific hooks:
# React projects (optional - only if you want hooks) npm install featurefly react # Vue 3 projects npm install featurefly vue
Tree-Shakeable Entry Points
FeatureFly supports granular imports for optimal bundle size. All sizes are gzipped.
| Entry Point | Approx. gzipped size | Use Case |
|-------------|----------------------|----------|
| featurefly | ~25KB (core + advanced) | Framework-agnostic: client plus EdgeEvaluator, streaming, metrics, targeting. Never loads React or Vue. |
| featurefly/core | ~13KB | Vanilla JS, Node.js, Serverless |
| featurefly/react | core + ~3KB hooks | React 18+ applications (FeatureFlyProvider, useFeatureFlag, useAllFlags) |
| featurefly/vue | core + ~2KB composables | Vue 3 applications (FeatureFlyPlugin, useFeatureFlag, useAllFlags) |
| featurefly/advanced | ~15KB | EdgeEvaluator, FlagStreamClient, ImpactMetrics, targeting, rollout, experiments |
Note: Sizes are approximate: the gzipped size of each entry's unminified ESM output (
dist/esm) with its relative imports concatenated, measured for this release (competitor figures in the comparison table are explained under it). The root entry (featurefly) re-exportsfeaturefly/coreandfeaturefly/advancedonly; React and Vue symbols are not available from it, import them fromfeaturefly/reactandfeaturefly/vue. At runtime,FeatureFlagsClientloads the edge evaluator and the stream client with dynamicimport()the first time they are needed (anedgeDocumentevaluation, orstartStreaming()), so apps that never use them do not execute that code. Impact metrics are opt-in (metrics: true).The package ships both ESM and CJS builds (native
importandrequire()), with"sideEffects": falsefor tree-shaking.
// Minimal - tree-shakeable core (~13KB gzipped)
import { FeatureFlagsClient } from 'featurefly/core';
// React - hooks live on their own subpath
import { FeatureFlyProvider, useFeatureFlag } from 'featurefly/react';
// Advanced features
import { EdgeEvaluator, FlagStreamClient } from 'featurefly/advanced';🚀 Quick Start
import { FeatureFlagsClient } from "featurefly";
const client = new FeatureFlagsClient({
baseUrl: "https://your-api.com",
apiKey: "your-api-key",
});
const isEnabled = await client.evaluateFlag("new-checkout-flow", false);
if (isEnabled) {
// New checkout
} else {
// Legacy checkout
}
// Always dispose when done (servers: on shutdown, SPAs: on unmount)
client.dispose();That's it. The same code works in a NestJS service, an Express middleware, a Vite frontend, or a Next.js API route.
🌍 Usage by Environment
FeatureFly is universal — the same npm package runs on the server and in the browser. The only difference is how you integrate it.
🖥️ Backend (Node.js / NestJS / Express)
Use the client directly to evaluate flags on the server side, typically in middleware, guards, or services.
// services/feature-flags.service.ts
import { FeatureFlagsClient } from "featurefly";
const client = new FeatureFlagsClient({
baseUrl: process.env.FEATURE_FLAGS_API_URL,
apiKey: process.env.FEATURE_FLAGS_API_KEY,
cacheTtlMs: 30_000, // Cache flags for 30s on the server
});
export async function isFeatureEnabled(
slug: string,
userId?: string,
workspaceId?: string,
): Promise<boolean> {
return client.evaluateFlag(slug, false, { userId, workspaceId });
}// Example: Express middleware
app.get("/dashboard", async (req, res) => {
const showNewDashboard = await isFeatureEnabled(
"new-dashboard",
req.user.id,
req.user.workspaceId,
);
if (showNewDashboard) {
return res.render("dashboard-v2");
}
return res.render("dashboard");
});// Example: NestJS guard
@Injectable()
export class FeatureFlagGuard implements CanActivate {
constructor(private readonly flags: FeatureFlagsClient) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
return this.flags.evaluateFlag("beta-api", false, {
userId: request.user.id,
});
}
}💡 Tip: On the server, create a single instance of
FeatureFlagsClientand reuse it across requests. Callclient.dispose()during graceful shutdown.
🌐 Frontend (Vanilla JS / Any bundler)
Works with any bundler (Vite, Webpack, esbuild, Rollup) or even a plain <script> tag.
import { FeatureFlagsClient } from "featurefly";
const client = new FeatureFlagsClient({
baseUrl: "https://your-api.com",
apiKey: "pk_live_xxx", // Use a public/client key
streaming: true, // Enables SSE support; call startStreaming() to connect
});
client.startStreaming(); // streaming does NOT auto-connect
// Evaluate and render
const showBanner = await client.evaluateFlag("promo-banner", false, {
userId: currentUser.id,
attributes: { plan: currentUser.plan, country: "AR" },
});
if (showBanner) {
document.getElementById("promo")!.style.display = "block";
}
// React to live flag changes
client.on("flagsUpdated", async () => {
const updated = await client.evaluateFlag("promo-banner", false);
document.getElementById("promo")!.style.display = updated ? "block" : "none";
});⚛️ React
Note: Requires React 18+ (peer dependency of
featurefly/react).
Import from featurefly/react. Hooks auto-re-evaluate when flags change via streaming (call client.startStreaming() once to open the stream).
The root entry no longer exports React or Vue symbols (
useFeatureFlagReact/useAllFlagsReactwere removed). Always import hooks fromfeaturefly/react.
import { FeatureFlagsClient } from "featurefly";
import {
FeatureFlyProvider,
useFeatureFlag,
useAllFlags,
} from "featurefly/react";
// Create your client (once, outside the component tree)
const client = new FeatureFlagsClient({
baseUrl: "https://your-api.com",
apiKey: "pk_live_xxx",
streaming: true,
});
client.startStreaming(); // required: streaming: true alone does not connect
// 1. Wrap your app
function App() {
return (
<FeatureFlyProvider client={client}>
<MyComponent />
</FeatureFlyProvider>
);
}
// 2. Use hooks
function MyComponent() {
const { value: darkMode, loading } = useFeatureFlag("dark-mode", false);
const { flags } = useAllFlags({ workspaceId: "ws-123" });
if (loading) return <Spinner />;
return (
<div className={darkMode ? "dark" : "light"}>
{flags["new-feature"] && <NewFeature />}
</div>
);
}| Hook | Returns | Description |
| ---------------------------------------------- | --------------------------- | ----------------------------------------------- |
| useFeatureFlag(slug, defaultValue, context?) | { value, loading, error } | Evaluates a single flag. Re-renders on changes. |
| useAllFlags(context?) | { flags, loading, error } | Returns all flags as a key-value object. |
💚 Vue 3
Note: Requires Vue 3.0+ (Composition API support).
Import from featurefly/vue. Composables return reactive Ref values that update automatically.
// main.ts
import { createApp } from "vue";
import { FeatureFlagsClient } from "featurefly";
import { FeatureFlyPlugin } from "featurefly/vue";
const client = new FeatureFlagsClient({
baseUrl: "https://your-api.com",
apiKey: "pk_live_xxx",
streaming: true,
});
client.startStreaming(); // required: streaming: true alone does not connect
const app = createApp(App);
app.use(FeatureFlyPlugin, { client });
app.mount("#app");<!-- MyComponent.vue -->
<script setup>
import { useFeatureFlag, useAllFlags } from "featurefly/vue";
const darkMode = useFeatureFlag("dark-mode", false);
const flags = useAllFlags({ workspaceId: "ws-123" });
</script>
<template>
<div :class="{ dark: darkMode }">
<NewFeature v-if="flags['new-feature']" />
</div>
</template>| Composable | Returns | Description |
| ---------------------------------------------- | -------------------------------- | ------------------------------------------ |
| useFeatureFlag(slug, defaultValue, context?) | Ref<T> + loading, error refs | Reactive ref that updates on flag changes. |
| useAllFlags(context?) | Ref<Record<string, FlagValue>> + loading, error refs | Reactive ref with all flags. |
🔧 Configuration
All options are optional except baseUrl.
const client = new FeatureFlagsClient({
// Required — your feature flags API endpoint
baseUrl: "https://your-api.com",
// Authentication
apiKey: "your-api-key",
// HTTP timeout (default: 10000ms)
timeout: 10_000,
// Cache (default: enabled, 60s TTL, bounded to 1000 entries, LRU eviction)
cacheEnabled: true,
cacheTtlMs: 60_000,
cacheMaxSize: 1_000,
// Retry (default: 3 attempts, 1s base delay). 4xx responses are not retried
// (except 408 and 429) and do not count toward the circuit breaker.
retry: {
maxAttempts: 3,
baseDelayMs: 1_000,
maxDelayMs: 10_000,
},
// Circuit Breaker (default: 5 failures, 30s reset)
circuitBreaker: {
failureThreshold: 5,
resetTimeoutMs: 30_000,
},
// Logging — 'debug' | 'info' | 'warn' | 'error' | 'silent' (default: 'warn')
logLevel: "warn",
// Or provide your own logger (pino, winston, etc.)
logger: myLogger,
// Local overrides — flags evaluated instantly without HTTP
localOverrides: {
"feature-x": true,
"variant-test": "blue",
},
// Pre-evaluated flags to hydrate the client instantly (e.g. injected by Next.js/SSR)
bootstrapFlags: {
"new-checkout": true
},
// Fallback defaults when the API is unreachable
fallbackDefaults: {
"critical-feature": false,
},
// Custom static headers merged into every request (baseUrl may include a path
// prefix, e.g. "https://host/api/v1"; it is preserved)
headers: {
"x-workspace-id": "ws-123",
"x-custom-header": "value",
},
// Dynamic request interceptor — invoked before every HTTP call
// Perfect for JWT auth where tokens rotate over time
requestInterceptor: () => ({
Authorization: `Bearer ${getAccessToken()}`,
"x-user-id": getUserId(),
}),
// Send cookies with cross-origin requests (default: false)
withCredentials: true,
// Real-time updates via SSE (default: false). This only enables streaming;
// you must still call client.startStreaming() to open the connection.
streaming: true, // or { reconnectDelayMs: 2000, maxReconnectAttempts: 50, stableConnectionMs: 30_000 }
// Client-side impact metrics (default: false). When off, getImpactMetrics() resolves to null.
metrics: true,
// A/B testing callback
trackingCallback: (assignment) => {
analytics.track("Experiment Viewed", assignment);
},
// Edge evaluation — pass a pre-fetched document for fully offline mode
// edgeDocument: myPreFetchedDoc,
});📚 API Reference
Flag Evaluation
// Boolean flag
// Signature: evaluateFlag(slug, defaultValue, context?)
const isEnabled = await client.evaluateFlag("my-flag", false);
// With user context (targeting, rollout, experiments)
const isEnabledForUser = await client.evaluateFlag("my-flag", false, {
workspaceId: "ws-123",
userId: "user-456",
attributes: { plan: "pro", country: "AR" },
});
// Typed flag values
const variant = await client.evaluateFlag<string>("ab-test", "control");
const limit = await client.evaluateFlag<number>("rate-limit", 100);
const config = await client.evaluateFlag<Record<string, unknown>>("ui-config", {});
// All flags at once (single HTTP request)
const allFlags = await client.evaluateAllFlags({ workspaceId: "ws-123" });Flag Management (CRUD)
// Create
const flag = await client.createFlag({
slug: "new-feature",
name: "New Feature",
category: "both",
valueType: "boolean",
defaultValue: false,
tags: ["v2", "beta"],
});
// Read
const allFlags = await client.getAllFlags();
const byId = await client.getFlagById("flag-id");
const bySlug = await client.getFlagBySlug("my-flag");
// Update
await client.updateFlag("flag-id", { name: "Updated Name" });
// Delete
await client.deleteFlag("flag-id");Workspace Flags
// Set a workspace-level override
await client.setWorkspaceFlag("feature-x", "workspace-123", true);
// Get all flags for a workspace
const flags = await client.getWorkspaceFlags("workspace-123");
// Remove an override
await client.removeWorkspaceFlag("feature-x", "workspace-123");Real-time Streaming (SSE)
streaming: true (or a StreamingConfig object) only enables streaming support. It does not connect automatically: call client.startStreaming() to open the connection.
const client = new FeatureFlagsClient({
baseUrl: "https://your-api.com",
streaming: {
reconnectDelayMs: 1_000, // base delay for exponential backoff (default 1000)
maxReconnectDelayMs: 30_000, // backoff ceiling (default 30000)
maxReconnectAttempts: 50, // consecutive attempts before giving up (default 50, Infinity = never)
stableConnectionMs: 30_000, // a connection open this long counts as stable and resets the attempt counter (default 30000)
},
});
client.startStreaming(); // connect (the stream module is loaded on first call)
client.stopStreaming(); // disconnect
// React to flag updates (cache is auto-invalidated)
client.on("flagsUpdated", () => {
console.log("Flags refreshed from server!");
});Behavior:
- Transport: when an
apiKey, customheaders, or arequestInterceptoris configured, streaming uses a fetch-based SSE client (credentials go in headers, never in the URL).EventSourcecannot send headers, so it is used only when none of those are set, and only if the runtime provides it (otherwise streaming is disabled with a warning). - Reconnect: the client reconnects with exponential backoff, including after a clean server-side close. The attempt counter resets once a connection is stable: either it delivers its first valid message, or it stays open for
stableConnectionMs(default 30000) even if no messages arrive. A server that accepts and immediately closes therefore still hitsmaxReconnectAttempts. After the cap, astreamDisconnectedevent with an error is emitted and no further reconnects happen until you callstartStreaming()again. - Terminal errors: non-retryable 4xx responses (such as 401/403; 408 and 429 are retried) stop the stream permanently and emit
streamDisconnectedonce.
Edge Evaluation (Offline Mode)
Evaluate flags with 0ms latency by pre-fetching the entire flag document.
// 1. Fetch the document (e.g., at server startup or on app boot)
const doc = await fetch("https://your-api.com/feature-flags/document").then(
(r) => r.json(),
);
// 2. Create a fully offline client
const edgeClient = new FeatureFlagsClient({
baseUrl: "https://your-api.com",
edgeDocument: doc,
});
// 3. Evaluate — zero network calls, zero latency
const value = await edgeClient.evaluateFlag("my-flag", false, {
userId: "user-123",
});SSR & Bootstrapping (Zero-Flicker)
For Server-Side Rendering (Next.js, Nuxt, Remix), you can inject pre-evaluated flags from the server to the client. This hydrates the client cache instantly, ensuring loading is false on the first render and avoiding any UI flickering.
// 1. Server evaluates flags and injects them into the HTML
const serverFlags = await serverClient.evaluateAllFlags({ userId });
window.__FEATURE_FLAGS__ = serverFlags;
// 2. Client initializes with bootstrap flags (no initial HTTP request made)
const client = new FeatureFlagsClient({
baseUrl: "https://your-api.com",
apiKey: "pk_live_xxx",
bootstrapFlags: window.__FEATURE_FLAGS__,
});Impact Metrics
Client-side telemetry collected passively, no external calls. Opt-in: enable it with metrics: true; by default no collector is created and getImpactMetrics() resolves to null.
const client = new FeatureFlagsClient({
baseUrl: "https://your-api.com",
metrics: true,
});
// Get metrics snapshot (async; null when metrics are not enabled)
const metrics = await client.getImpactMetrics();
if (metrics) {
console.log(metrics.totalEvaluations); // 1523
console.log(metrics.cacheHitRate); // 0.87
console.log(metrics.latency.p50); // 2ms
console.log(metrics.latency.p95); // 12ms
console.log(metrics.latency.p99); // 45ms
// Per-flag detail
console.log(metrics.flags["my-flag"].evaluations); // 42
// Experiment exposures
console.log(metrics.experiments["checkout-exp"].exposures); // 300
}
// Reset all counters (async; no-op when metrics are not enabled)
await client.resetMetrics();Analytics
// Get flag statistics overview
const stats = await client.getFlagStats();
console.log(stats.totalFlags);
console.log(stats.enabledFlags);
// Get flags filtered by category
const frontendFlags = await client.getFlagsByCategory('frontend');
const backendFlags = await client.getFlagsByCategory('backend');
const bothFlags = await client.getFlagsByCategory('both');
// Get flags by target service
const apiFlags = await client.getFlagsByTargetService('api-gateway');Event System
// Subscribe to events
client.on("flagEvaluated", ({ slug, value, reason, durationMs }) => {
analytics.track("flag_check", { slug, value, reason, durationMs });
});
// Flag value changed (detect drift)
client.on("flagChanged", ({ slug, previousValue, newValue }) => {
console.log(`${slug}: ${previousValue} → ${newValue}`);
});
// Circuit breaker
client.on("circuitOpen", ({ state, failures }) => {
alerting.send(`Circuit opened after ${failures} failures`);
});
client.on("circuitClosed", () => console.log("Circuit closed"));
client.on("circuitHalfOpen", () => console.log("Circuit half-open"));
// Cache
client.on("cacheHit", ({ key }) => monitor.increment("cache.hit"));
client.on("cacheMiss", ({ key }) => monitor.increment("cache.miss"));
client.on("cacheCleared", () => console.log("Cache cleared"));
// Streaming
client.on("streamConnected", () => console.log("SSE connected"));
client.on("streamDisconnected", () => console.log("SSE lost"));
client.on("flagsUpdated", ({ slugs }) => console.log(`Flags updated: ${slugs?.join(', ') || 'all'}`));
// Request failures
client.on("requestFailed", ({ endpoint, error, attempt }) => {
console.error(`Request to ${endpoint} failed (attempt ${attempt}): ${error}`);
});
// Listener errors
client.on("listenerError", ({ event, error }) => {
console.error(`Error in ${event} listener:`, error);
});
// Subscribe once (auto-unsubscribes after first event)
client.once("flagEvaluated", ({ slug }) => {
console.log(`First evaluation for ${slug}`);
});
// Unsubscribe
const unsubscribe = client.on("flagEvaluated", handler);
unsubscribe();Local Overrides
Useful for development and testing — evaluated instantly, no HTTP.
client.setLocalOverride("experimental-ui", true);
client.setLocalOverride("theme", "dark");
const overrides = client.getLocalOverrides();
client.removeLocalOverride("experimental-ui");
client.clearLocalOverrides();Cache Access (Synchronous)
// Get a single cached flag value (sync, no HTTP)
// Returns undefined if not in cache
const cachedValue = client.getCachedFlag<boolean>("my-flag", { userId: "123" });
if (cachedValue !== undefined) {
console.log("From cache:", cachedValue);
}
// Get all cached batch-evaluated flags (sync)
// Returns undefined if no batch evaluation was cached
const allCached = client.getCachedFlags({ workspaceId: "ws-123" });
if (allCached) {
console.log("All cached flags:", allCached);
}
// Clear cache
client.clearCache();
// Get cache statistics
const stats = client.getCacheStats();
console.log(`Cache size: ${stats.size}, keys: ${stats.keys.length}, enabled: ${stats.enabled}`);Edge Evaluation (Advanced)
// Load edge document for offline evaluation mode
// After calling this, evaluations happen locally (zero HTTP)
await client.loadEdgeDocument();
// Streaming is a separate opt-in (config `streaming` + startStreaming())
client.startStreaming(); // Start SSE connection
client.stopStreaming(); // Stop SSE connectionUtilities
// Circuit breaker
client.getCircuitBreakerState(); // { state, 'open'|'closed'|'half-open', failures }
client.resetCircuitBreaker(); // Manually reset to closed state
// Lifecycle
client.dispose(); // Release timers, listeners, metrics, SSE connections
client.isDisposed(); // true if disposed⚠️ Considerations
Universal Package (Backend + Frontend)
FeatureFly ships as a single universal package that works on both the server and the browser:
| Concern | How it's handled |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP client | Uses the global fetch (Node.js >= 18 and browsers). baseUrl path prefixes are preserved and path parameters (slugs, ids) are URL-encoded. |
| Hashing (MurmurHash3) | Implemented in pure TypeScript over the UTF-8 bytes of the key — no dependency on Node.js crypto. Percentage rollouts and experiments are sticky per salt:key; ASCII keys bucket exactly as before, non-ASCII keys, salts or slugs land in different buckets than releases before this change. |
| Targeting regex | The regex operator is guarded against ReDoS: patterns longer than 256 characters never match, patterns with nested quantifiers (e.g. (a+)+) never match, and context values longer than 1024 characters are not evaluated. Invalid patterns never match (no throw). |
| Attributes | EvaluationContext.attributes accepts JSON values (strings, numbers, booleans, null, arrays and nested objects). |
| SSE Streaming | Fetch-based SSE client when an apiKey, custom headers or a requestInterceptor is set (works on Node.js 18+); native EventSource only when none are set and the runtime provides it. |
| Build format | Ships both native ESM (import) and CJS (require()) with matching type declarations. Node.js and bundlers pick the right one. |
| Cache bound | The cache holds at most cacheMaxSize entries (default 1000) and evicts least-recently-used entries. |
API Key Security
- On the backend, use a secret API key stored in environment variables.
- On the frontend, use a public/read-only key that only has permission to evaluate flags, not manage them. Never expose your secret key in client-side code.
Single Instance Pattern
Create one FeatureFlagsClient instance and share it:
- Backend: Create at app startup, dispose on
SIGTERM/SIGINT. - React: Create outside the component tree, pass via
<FeatureFlyProvider>. - Vue: Create before
app.mount(), install viaapp.use(FeatureFlyPlugin, { client }).
Cache Strategy
| Environment | Recommended cacheTtlMs | Why |
| ----------------- | ------------------------ | -------------------------------------------------------------------------- |
| Backend (API) | 30_000 – 60_000 | Flags change infrequently; caching reduces load on the flags API. |
| Frontend (SPA) | 60_000 – 120_000 | Combined with streaming (startStreaming()), the cache is auto-invalidated on changes. |
| Edge / Serverless | 0 (disabled) | Use edgeDocument for fully offline evaluation instead. |
Disposing Resources
Always call client.dispose() when you're done. This cleans up:
- SSE connections
- Cache timers
- Metrics collectors
- Event listeners
// Express / NestJS
process.on("SIGTERM", () => client.dispose());
// React
useEffect(() => () => client.dispose(), []);
// Vue
onUnmounted(() => client.dispose());Node.js Version
Requires Node.js >= 18. Streaming works on Node.js 18+ when an apiKey, headers or requestInterceptor is configured (fetch-based client). Without any of these it falls back to EventSource, which needs Node.js 22+ or a polyfill such as eventsource.
⚙️ Evaluation Flow
When you call evaluateFlag(), the SDK follows this priority chain:
evaluateFlag('slug', defaultValue, context)
│
├─ 1. Local Overrides → instant return (no HTTP)
├─ 2. Edge Evaluator → offline return if document loaded
├─ 3. Cache hit → instant return (no HTTP)
├─ 4. Circuit Breaker → reject if circuit is open
├─ 5. Retry w/ Backoff → exponential backoff + jitter
├─ 6. HTTP Request → GET /feature-flags/:slug/evaluate
├─ 7. Cache store → persist result with TTL
└─ 8. Fallback → predefined defaults if everything fails🔒 Resilience
Built-in, zero-config resilience. No plugins needed.
| Layer | What it does | | --------------------- | ----------------------------------------------------------------------- | | Retry | Retries failed requests with exponential backoff + jitter (4xx except 408/429 are not retried) | | Circuit Breaker | Stops calling a failing API after N consecutive failures (client errors 4xx other than 408/429 do not count), auto-recovers | | Cache | Serves stale data while the API is down | | Fallback Defaults | Returns predefined safe values when nothing else works | | Local Overrides | Flags work completely offline | | Edge Evaluator | Full offline evaluation using a pre-fetched document |
📊 Feature Comparison
| Capability | FeatureFly | LaunchDarkly | Unleash | GrowthBook | Flagsmith | | ---------------------------------------- | :--------: | :-----------: | :-----------: | :--------: | :-------: | | Boolean flags | ✅ | ✅ | ✅ | ✅ | ✅ | | Multi-type values (string, number, JSON) | ✅ | ✅ | ✅ | ✅ | ✅ | | In-memory cache | ✅ | ✅ | ✅ | ✅ | ✅ | | Retry with backoff | ✅ | ✅ | ✅ | ✅ | ✅ | | Circuit breaker | ✅ | ✅ | ❌ | ❌ | ❌ | | Typed event system | ✅ | ⚠️ | ❌ | ❌ | ❌ | | Local overrides | ✅ | ⚠️ | ✅ | ✅ | ❌ | | Fallback defaults | ✅ | ✅ | ✅ | ✅ | ✅ | | Injectable logger | ✅ | ✅ | ✅ | ❌ | ❌ | | Dispose / cleanup | ✅ | ✅ | ✅ | ❌ | ❌ | | Workspace-level overrides | ✅ | ⚠️ | ❌ | ❌ | ✅ | | Streaming (SSE) | ✅ | ✅ | ✅ | ✅ | ✅ | | Targeting / segmentation | ✅ | ✅ | ✅ | ✅ | ✅ | | Percentage rollout | ✅ | ✅ | ✅ | ✅ | ✅ | | A/B testing | ✅ | ✅ | ❌ | ✅ | ✅ | | Edge evaluation (offline) | ✅ | ✅ | ✅ | ✅ | ❌ | | Impact metrics | ✅ | ✅ | ✅ | ✅ | ❌ | | React hooks | ✅ | ✅ | ✅ | ✅ | ✅ | | Vue composables | ✅ | ❌ | ❌ | ❌ | ❌ | | Self-hosted | ✅ | ❌ | ✅ | ✅ | ✅ | | TypeScript first | ✅ | ✅ | ⚠️ | ✅ | ⚠️ | | Open source | ✅ MIT | ✅ Apache 2.0 | ✅ Apache 2.0 | ✅ MIT | ✅ BSD-3 | | | | | | | | | Bundle size (gzipped) | ~13 KB core~25 KB full | ~18 KB | ~8 KB | ~16 KB | ~9 KB | | Runtime dependencies | 0 | 2 | 1 | 1 | 0 |
✅ Supported · ⚠️ Partial · ❌ Not available
Method (as of 2026-10-20): bundle size is the gzip figure reported by the Bundlephobia API for the browser client packages
[email protected],[email protected],@growthbook/[email protected]and[email protected]; runtime dependencies come fromnpm view <package> dependenciesfor the same versions. FeatureFly's own sizes are measured as described above (unminified ESM output, so not strictly comparable). Server-side SDKs (for exampleunleash-client) are larger and have more dependencies. The capability rows come from each project's public documentation and have not been exhaustively re-verified; check the vendors' docs before deciding. Pricing is intentionally not compared because it changes frequently.
📝 Changelog
All notable changes to this project are documented in CHANGELOG.md.
⬆️ Upgrading to 0.4.0
0.4.0 contains breaking changes. The full list, with a migration step for each, is in the CHANGELOG. The short version:
| If you... | Do this |
| --- | --- |
| import React/Vue symbols from featurefly | import them from featurefly/react or featurefly/vue (the hooks keep their plain names) |
| rely on caching more than 1000 distinct entries | set cacheMaxSize |
| use rollouts or experiments with non-ASCII keys, salts or slugs | expect those users to be re-bucketed (ASCII buckets are unchanged) |
| use regex targeting rules | keep patterns <= 256 chars, avoid nested quantifiers like (a+)+, inputs are capped at 1024 chars |
| read EvaluationContext.attributes | the type is now a JSON value type, narrow before use |
| compare prerelease versions in targeting | numeric identifiers now sort below alphanumeric ones (semver 2.0.0) |
🗺️ Roadmap
| Feature | Status | Description | | ------------------- | ------------ | -------------------------------------------- | | Multi-language SDKs | 🔜 Planned | Go, Python, Ruby, PHP server-side SDKs | | Encrypted payloads | 💡 Exploring | End-to-end encryption of flag configurations | | Audit log | 💡 Exploring | Track who changed what and when |
🧪 Testing
npm test # Vitest, single run with coverage
npm run test:watch # Vitest watch mode🤝 Contributing
- Clone the repo
npm installnpm test(Vitest)npm run lintnpm run build
📄 License
MIT © Arrua Platform Team
All notable changes to this project are documented in CHANGELOG.md.
