All posts
2 Sep 2026

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:

  1. In-memory local caching for sub-millisecond evaluation.
  2. Redis Pub/Sub for real-time cache invalidation across clustered Node.js instances.
  3. Deterministic percentage-based rollouts using consistent hashing (MurmurHash3) to ensure stable user experiences.
  4. Advanced user targeting rules (e.g., targeting specific user IDs, emails, or company tiers).
  5. 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.

code
+--------------------+         +-------------------+         +----------------------+
| 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/catch blocks and default to safe fallback values (usually false).

  • 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.

More posts