Dynamic Feature Flags in Node.js: Building a Low-Latency Evaluation Engine with Redis
Learn how to build a zero-downtime, low-latency feature flag system in Node.js using Redis pub/sub, local in-memory caching, percentage rollouts, and deterministic hashing.
Dynamic Feature Flags in Node.js: Building a Low-Latency Evaluation Engine with Redis
Feature flags (or feature toggles) are a staple of modern backend engineering. They decouple code deployments from feature releases, enabling canary deployments, kill switches, and user-targeted rollouts. However, as traffic scales into tens of thousands of requests per second, traditional database-backed feature flags introduce noticeable latency overhead.
In this architectural guide, we will build a production-grade, low-latency feature flag evaluation engine in Node.js. Our system will feature:
- In-memory local caching for sub-millisecond evaluation.
- Redis Pub/Sub for real-time cache invalidation across clustered Node.js instances.
- Deterministic percentage-based rollouts using consistent hashing (MurmurHash3) to ensure stable user experiences.
- Advanced user targeting rules (e.g., targeting specific user IDs, emails, or company tiers).
- Audit logging to track flag modifications.
System Architecture Overview
To achieve zero-downtime and sub-millisecond latency, we must avoid making a network call to a database or cache on every single request. Instead, each Node.js worker instance maintains an in-memory cache of all active flags and their rules.
When a flag is updated via an administrative API, the change is persisted to Redis and a broadcast event is published via Redis Pub/Sub. All running Node.js instances instantly receive this message and invalidate or update their local memory cache. This gives us the speed of memory reads with the centralized management of Redis.
+--------------------+ +-------------------+ +----------------------+
| Admin / API Client | ------->| Redis (Data Store | ------->| Redis Pub/Sub Channel|
+--------------------+ +-------------------+ +----------------------+
|
+------------------------------+
| (Broadcasts Invalidation)
v
+----------------------------------+
| Node.js Cluster Instance |
| - In-Memory Flag Cache |
| - Local Evaluation Engine |
+----------------------------------+
Step 1: The Core Evaluation Engine
Let’s design our evaluation engine. A flag schema needs to support global enablement, percentage rollouts, targeted user inclusions/exclusions, and targeted lists.
Create a file named engine.js:
const crypto = require('crypto');
/**
* Deterministically hashes a string to a number between 0 and 99.
* Useful for stable percentage rollouts.
*/
function getPercentageBucket(userId, flagKey) {
const hash = crypto
.createHash('sha256')
.update(`${flagKey}:${userId}`)
.digest('hex');
// Take the first 8 hex characters and convert to an integer, then modulo 100
const intVal = parseInt(hash.slice(0, 8), 16);
return intVal % 100;
}
class FeatureFlagEngine {
constructor() {
this.flags = new Map();
}
setFlag(key, flagData) {
this.flags.set(key, flagData);
}
removeFlag(key) {
this.flags.delete(key);
}
loadAll(flagsObject) {
this.flags.clear();
for (const [key, value] of Object.entries(flagsObject)) {
this.flags.set(key, value);
}
}
evaluate(flagKey, context = {}) {
const flag = this.flags.get(flagKey);
// 1. If flag doesn't exist, default to false
if (!flag || !flag.enabled) {
return false;
}
const { userId, email, tier } = context;
// 2. Explicit User ID Targeting (Whitelist)
if (flag.targetUsers && Array.isArray(flag.targetUsers)) {
if (userId && flag.targetUsers.includes(userId)) {
return true;
}
}
// 3. Explicit User ID Exclusion (Blacklist)
if (flag.excludeUsers && Array.isArray(flag.excludeUsers)) {
if (userId && flag.excludeUsers.includes(userId)) {
return false;
}
}
// 4. Attribute-based Targeting (e.g., Target specific tiers)
if (flag.targetTiers && Array.isArray(flag.targetTiers)) {
if (tier && flag.targetTiers.includes(tier)) {
return true;
}
}
// 5. Percentage Rollout
if (typeof flag.rolloutPercentage === 'number' && flag.rolloutPercentage > 0) {
if (!userId) {
// Fallback if no user context is provided for percentage rollout
return false;
}
const userBucket = getPercentageBucket(userId, flagKey);
return userBucket < flag.rolloutPercentage;
}
// 6. Default fallback to global flag status
return flag.enabled;
}
}
module.exports = FeatureFlagEngine;
Step 2: Integrating Redis and Pub/Sub Synchronization
Next, we need a manager that hooks our FeatureFlagEngine into Redis. This manager will fetch initial flags on startup, write updates, and listen for Redis Pub/Sub messages to synchronize in-memory state across multiple processes.
Create a file named manager.js:
const Redis = require('ioredis');
const FeatureFlagEngine = require('./engine');
class DistributedFeatureFlagManager {
constructor(redisConfig) {
this.engine = new FeatureFlagEngine();
this.redis = new Redis(redisConfig);
this.subscriber = new Redis(redisConfig);
this.channelName = 'feature_flags:invalidate';
}
async init() {
// 1. Load initial flags from Redis
await this.fetchAllFlags();
// 2. Subscribe to Redis Pub/Sub for cross-instance invalidation
await this.subscriber.subscribe(this.channelName);
this.subscriber.on('message', async (channel, message) => {
if (channel === this.channelName) {
console.log(`[FeatureFlag] Received invalidation signal for flag: ${message}`);
await this.fetchAllFlags();
}
});
}
async fetchAllFlags() {
const allFlagsRaw = await this.redis.hgetall('feature_flags');
const parsedFlags = {};
for (const [key, jsonString] of Object.entries(allFlagsRaw)) {
try {
parsedFlags[key] = JSON.parse(jsonString);
} catch (err) {
console.error(`Failed to parse flag ${key}:`, err);
}
}
this.engine.loadAll(parsedFlags);
}
async evaluate(flagKey, context) {
// Pure in-memory lookup - zero network overhead
return this.engine.evaluate(flagKey, context);
}
async updateFlag(key, flagData, adminUser = 'system') {
const serialized = JSON.stringify(flagData);
// Persist to Redis Hash
await this.redis.hset('feature_flags', key, serialized);
// Write to audit log stream
await this.logAudit(key, flagData, adminUser);
// Publish invalidation event to all Node.js instances
await this.redis.publish(this.channelName, key);
}
async logAudit(key, flagData, adminUser) {
const auditEntry = {
timestamp: new Date().toISOString(),
flagKey: key,
updatedBy: adminUser,
payload: JSON.stringify(flagData)
};
await this.redis.xadd('feature_flags:audit', '*',
'timestamp', auditEntry.timestamp,
'flagKey', auditEntry.flagKey,
'updatedBy', auditEntry.updatedBy,
'payload', auditEntry.payload
);
}
async close() {
await this.redis.quit();
await this.subscriber.quit();
}
}
module.exports = DistributedFeatureFlagManager;
Step 3: Express.js Integration and Middleware
Let’s see how we can wire this up in a standard Express application to evaluate flags per request and inject an evaluation helper into req.
Create a file named server.js:
const express = require('express');
const DistributedFeatureFlagManager = require('./manager');
const app = express();
app.use(express.json());
const flagManager = new DistributedFeatureFlagManager({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379
});
// Context Injection Middleware
app.use((req, res, next) => {
// Extract user context from headers, session, or JWT
req.userContext = {
userId: req.headers['x-user-id'],
email: req.headers['x-user-email'],
tier: req.headers['x-user-tier'] || 'free'
};
next();
});
// Example API Endpoint leveraging feature flags
app.get('/api/dashboard', async (req, res) => {
const isNewDashboardEnabled = await flagManager.evaluate('new_dashboard', req.userContext);
if (isNewDashboardEnabled) {
return res.json({ layout: 'v2-grid', widgets: ['revenue', 'active_users', 'ai_insights'] });
}
res.json({ layout: 'v1-classic', widgets: ['revenue', 'active_users'] });
});
// Admin endpoint to update flags
app.post('/api/admin/flags/:key', async (req, res) => {
const { key } = req.params;
const flagData = req.body; // { enabled, rolloutPercentage, targetUsers, etc. }
const adminUser = req.headers['x-admin-user'] || 'unknown';
try {
await flagManager.updateFlag(key, flagData, adminUser);
res.json({ success: true, message: `Flag ${key} updated successfully.` });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
// Start server after connecting to Redis
const PORT = process.env.PORT || 3000;
flagManager.init().then(() => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
});
Step 4: Audit Logging with Redis Streams
In our DistributedFeatureFlagManager, we utilized Redis Streams (XADD) to record changes. Redis Streams provide an append-only log structure perfect for audit trails.
To read the audit history for compliance and troubleshooting, you can create a simple helper or worker script:
async function getAuditLogs(redisClient, count = 50) {
// XREVRANGE reads the stream in reverse chronological order
const entries = await redisClient.xrevrange('feature_flags:audit', '+', '-', 'COUNT', count);
return entries.map(([id, fields]) => {
const record = {};
for (let i = 0; i < fields.length; i += 2) {
record[fields[i]] = fields[i+1];
}
record.id = id;
record.payload = JSON.parse(record.payload);
return record;
});
}
Best Practices & Edge Cases
When running this architecture in production, keep the following considerations in mind:
Graceful Fallbacks: If Redis experiences a network partition or outage, your evaluation engine should not crash the application. Wrap evaluations in
try/catchblocks and default to safe fallback values (usuallyfalse).
- Memory Limits: Ensure that the number of feature flags stored in memory remains reasonable. Hundreds or thousands of flags consume negligible memory, but avoid storing massive payloads inside flag configurations.
- Clock Drift & Hashing Consistency: By using
crypto.createHash('sha256'), we ensure that percentage bucketing is completely deterministic across independent Node.js processes, serverless containers, or microservice boundaries without requiring a shared state lookup.
Conclusion
By combining local in-memory lookups with Redis Pub/Sub, we achieve the best of both worlds: sub-millisecond evaluation performance and instantaneous centralized control. This pattern scales effortlessly across multi-core containerized environments, allowing your team to ship code safely with robust percentage rollouts and precise user targeting.