A/B Testing & Feature Flag Platform
A production-grade experimentation platform with deterministic hashing, per-environment rollouts, a targeting rules engine, Redis caching, and a type-safe Node.js SDK.

A production-grade experimentation platform with deterministic hashing, per-environment rollouts, a targeting rules engine, Redis caching, and a type-safe Node.js SDK.

A full-stack A/B testing and feature flag platform built from scratch. Teams can create flags, run multivariate experiments, and evaluate them in under 5ms using a deterministic hashing algorithm, Redis caching, and a type-safe Node.js SDK — no LaunchDarkly, no Statsig.
Off-the-shelf experimentation tools are expensive and opaque. The goal was to build something that:
Bearer <env-api-key>ApiKeyGuard resolves the scoped EnvironmentSdkService checks Redis — cache hit returns immediately{ enabled, variant, reason } to the SDKOrganization
└── Project (projectId: "my-app-A3F2B")
├── Environment (development | staging | production)
├── ApiKey (scoped per environment)
└── FeatureFlag
├── Variant[] (name, weight: 0–100, value)
└── FeatureFlagEnvironment (enabled, rollout%, targetingRules)Separating FeatureFlag from FeatureFlagEnvironment means a flag can be
globally on but disabled in production, with a 20% rollout in staging — all
without any conditional spaghetti in the schema.
private getBucket(userId: string, flagKey: string, salt?: string): number {
const input = `${userId}${flagKey}${salt ?? ''}`;
const hex = createHash('sha256').update(input).digest('hex').slice(0, 8);
return parseInt(hex, 16) % 100; // always 0–99 for the same inputs
}SHA-256 on userId + flagKey + salt is deterministic across any server or
language. A per-flag hashSalt gives each user an independent bucket per
flag, eliminating correlation between concurrent experiments.
1. Cache Aside → Redis hit? Return immediately (0 DB queries)
2. Kill Switch → flag.enabled === false? → GLOBALLY_DISABLED
3. Env Override → ffEnv.enabled === false? → ENV_DISABLED
4. Targeting → rules don't match? → TARGETING_MISS
5. Rollout → bucket >= rollout%? → OUT_OF_ROLLOUT
6. Variant → assignVariant() → pick winner by cumulative weight
7. Log → createAssignmentLog() for experiment audit trail
8. Exposure → trackEvent('$exposure') for analyticsEach step returns early with a typed reason string — debugging is trivial
because you always know why a user got a certain result.
private evaluateTargetingRules(rules, attributes): boolean {
return rules.groups.some(group => // OR across groups
group.conditions.every(c => // AND within a group
this.evaluateCondition(c, attributes)
)
);
}12 operators: in, not_in, equals, not_equals, contains,
starts_with, ends_with, gt, gte, lt, lte, exists —
stored as JSONB, evaluated in-process, no query parser needed.
async flush(): Promise<void> {
if (this.queue.length === 0) return;
// Drain atomically BEFORE the async call — prevents double-send
const batch = this.queue.splice(0, this.queue.length);
try {
await this.http.post('/sdk/track/batch', { events: batch });
} catch {
this.queue.unshift(...batch); // retry: push failed batch back to front
}
}Events batch by size (default 100) and flush on a timer (default 10s).
Draining before the async call means concurrent push() calls during an
in-flight request are never lost — unshift on failure preserves order.
| Metric | Naive | This Platform |
|---|---|---|
| Evaluation latency (cache hit) | ~40ms | < 1ms |
| Evaluation latency (cache miss) | ~40ms | ~12ms |
| Variant consistency | ❌ | ✅ Deterministic |
| Event HTTP calls at 1k/s | 1,000 req/s | 10 req/s |
| Experiment correlation | High | Zero (per-flag salt) |
useFeatureFlag React hook