Skip to content

State Management

Overview

State management in Node.js applications involves handling data that persists across requests, managing user sessions, caching frequently accessed data, and coordinating state between different parts of your application. This chapter covers various state management strategies and implementations.

Session Management

Express Session with Memory Store

javascript
// session-basic.js
const express = require('express');
const session = require('express-session');

const app = express();

// Basic session configuration
app.use(session({
  secret: 'your-secret-key',
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: false, // Set to true in production with HTTPS
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000 // 24 hours
  }
}));

app.use(express.json());

// Login route
app.post('/login', (req, res) => {
  const { username, password } = req.body;
  
  // Simple authentication (use proper authentication in production)
  if (username === 'admin' && password === 'password') {
    req.session.user = {
      id: 1,
      username: 'admin',
      role: 'administrator'
    };
    
    req.session.loginTime = new Date();
    
    res.json({
      message: 'Login successful',
      user: req.session.user
    });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

// Protected route
app.get('/profile', (req, res) => {
  if (!req.session.user) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  
  res.json({
    user: req.session.user,
    loginTime: req.session.loginTime,
    sessionId: req.sessionID
  });
});

// Update session data
app.post('/preferences', (req, res) => {
  if (!req.session.user) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  
  req.session.preferences = req.body;
  
  res.json({
    message: 'Preferences updated',
    preferences: req.session.preferences
  });
});

// Logout route
app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: 'Could not log out' });
    }
    
    res.clearCookie('connect.sid'); // Default session cookie name
    res.json({ message: 'Logout successful' });
  });
});

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

Session with Redis Store

javascript
// session-redis.js
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const redis = require('redis');

const app = express();

// Create Redis client
const redisClient = redis.createClient({
  host: 'localhost',
  port: 6379,
  // password: 'your-redis-password'
});

redisClient.on('error', (err) => {
  console.error('Redis error:', err);
});

redisClient.on('connect', () => {
  console.log('Connected to Redis');
});

// Session configuration with Redis store
app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: 'your-secret-key',
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: false,
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000 // 24 hours
  }
}));

app.use(express.json());

// Session middleware to track user activity
app.use((req, res, next) => {
  if (req.session.user) {
    req.session.lastActivity = new Date();
  }
  next();
});

// Enhanced login with session tracking
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  
  if (username === 'admin' && password === 'password') {
    req.session.user = {
      id: 1,
      username: 'admin',
      role: 'administrator'
    };
    
    req.session.loginTime = new Date();
    req.session.loginCount = (req.session.loginCount || 0) + 1;
    
    // Store additional session data in Redis
    await redisClient.setex(
      `user:${req.session.user.id}:active_session`,
      3600, // 1 hour TTL
      req.sessionID
    );
    
    res.json({
      message: 'Login successful',
      user: req.session.user,
      loginCount: req.session.loginCount
    });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

// Get active sessions for a user
app.get('/sessions', async (req, res) => {
  if (!req.session.user) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  
  try {
    const activeSession = await redisClient.get(`user:${req.session.user.id}:active_session`);
    
    res.json({
      currentSession: req.sessionID,
      activeSession,
      isCurrentActive: req.sessionID === activeSession
    });
  } catch (error) {
    res.status(500).json({ error: 'Failed to get session info' });
  }
});

app.listen(3000);

In-Memory State Management

Application State Manager

javascript
// state-manager.js
const EventEmitter = require('events');

class StateManager extends EventEmitter {
  constructor() {
    super();
    this.state = new Map();
    this.subscribers = new Map();
    this.middleware = [];
  }

  // Add middleware for state changes
  use(middleware) {
    this.middleware.push(middleware);
  }

  // Get state value
  get(key) {
    return this.state.get(key);
  }

  // Set state value with middleware and events
  async set(key, value, context = {}) {
    const oldValue = this.state.get(key);
    
    // Run middleware
    let newValue = value;
    for (const middleware of this.middleware) {
      newValue = await middleware(key, newValue, oldValue, context);
    }
    
    this.state.set(key, newValue);
    
    // Emit change event
    this.emit('change', {
      key,
      oldValue,
      newValue,
      context
    });
    
    // Emit specific key change event
    this.emit(`change:${key}`, {
      oldValue,
      newValue,
      context
    });
    
    return newValue;
  }

  // Update state with a function
  async update(key, updater, context = {}) {
    const currentValue = this.get(key);
    const newValue = updater(currentValue);
    return this.set(key, newValue, context);
  }

  // Delete state
  delete(key) {
    const oldValue = this.state.get(key);
    const deleted = this.state.delete(key);
    
    if (deleted) {
      this.emit('delete', { key, oldValue });
      this.emit(`delete:${key}`, { oldValue });
    }
    
    return deleted;
  }

  // Check if key exists
  has(key) {
    return this.state.has(key);
  }

  // Get all keys
  keys() {
    return Array.from(this.state.keys());
  }

  // Get all values
  values() {
    return Array.from(this.state.values());
  }

  // Get state size
  size() {
    return this.state.size;
  }

  // Clear all state
  clear() {
    const oldState = new Map(this.state);
    this.state.clear();
    this.emit('clear', { oldState });
  }

  // Subscribe to state changes
  subscribe(key, callback) {
    const eventName = `change:${key}`;
    this.on(eventName, callback);
    
    // Return unsubscribe function
    return () => this.off(eventName, callback);
  }

  // Get snapshot of current state
  getSnapshot() {
    return Object.fromEntries(this.state);
  }

  // Restore state from snapshot
  restoreSnapshot(snapshot) {
    this.clear();
    for (const [key, value] of Object.entries(snapshot)) {
      this.state.set(key, value);
    }
    this.emit('restore', { snapshot });
  }
}

// Usage example
const stateManager = new StateManager();

// Add logging middleware
stateManager.use(async (key, newValue, oldValue, context) => {
  console.log(`State change: ${key}`, {
    from: oldValue,
    to: newValue,
    context
  });
  return newValue;
});

// Add validation middleware
stateManager.use(async (key, newValue, oldValue, context) => {
  if (key === 'userCount' && typeof newValue !== 'number') {
    throw new Error('userCount must be a number');
  }
  return newValue;
});

// Subscribe to changes
const unsubscribe = stateManager.subscribe('userCount', ({ oldValue, newValue }) => {
  console.log(`User count changed from ${oldValue} to ${newValue}`);
});

// Set initial state
stateManager.set('userCount', 0);
stateManager.set('appName', 'My Node.js App');

// Update state
stateManager.update('userCount', count => count + 1);

module.exports = StateManager;

User Session Store

javascript
// user-session-store.js
class UserSessionStore {
  constructor() {
    this.sessions = new Map();
    this.userSessions = new Map(); // userId -> Set of sessionIds
    this.sessionTimeout = 30 * 60 * 1000; // 30 minutes
    this.cleanupInterval = 5 * 60 * 1000; // 5 minutes
    
    this.startCleanup();
  }

  createSession(userId, sessionData = {}) {
    const sessionId = this.generateSessionId();
    const session = {
      id: sessionId,
      userId,
      data: sessionData,
      createdAt: new Date(),
      lastActivity: new Date(),
      isActive: true
    };

    this.sessions.set(sessionId, session);
    
    // Track user sessions
    if (!this.userSessions.has(userId)) {
      this.userSessions.set(userId, new Set());
    }
    this.userSessions.get(userId).add(sessionId);

    return session;
  }

  getSession(sessionId) {
    const session = this.sessions.get(sessionId);
    
    if (session && this.isSessionValid(session)) {
      session.lastActivity = new Date();
      return session;
    }
    
    return null;
  }

  updateSession(sessionId, data) {
    const session = this.sessions.get(sessionId);
    
    if (session && this.isSessionValid(session)) {
      session.data = { ...session.data, ...data };
      session.lastActivity = new Date();
      return session;
    }
    
    return null;
  }

  destroySession(sessionId) {
    const session = this.sessions.get(sessionId);
    
    if (session) {
      // Remove from user sessions
      const userSessions = this.userSessions.get(session.userId);
      if (userSessions) {
        userSessions.delete(sessionId);
        if (userSessions.size === 0) {
          this.userSessions.delete(session.userId);
        }
      }
      
      return this.sessions.delete(sessionId);
    }
    
    return false;
  }

  getUserSessions(userId) {
    const sessionIds = this.userSessions.get(userId);
    
    if (!sessionIds) {
      return [];
    }
    
    return Array.from(sessionIds)
      .map(id => this.sessions.get(id))
      .filter(session => session && this.isSessionValid(session));
  }

  destroyUserSessions(userId) {
    const sessionIds = this.userSessions.get(userId);
    
    if (sessionIds) {
      for (const sessionId of sessionIds) {
        this.sessions.delete(sessionId);
      }
      this.userSessions.delete(userId);
      return sessionIds.size;
    }
    
    return 0;
  }

  isSessionValid(session) {
    if (!session.isActive) {
      return false;
    }
    
    const now = new Date();
    const timeSinceActivity = now - session.lastActivity;
    
    return timeSinceActivity < this.sessionTimeout;
  }

  generateSessionId() {
    return require('crypto').randomBytes(32).toString('hex');
  }

  startCleanup() {
    setInterval(() => {
      this.cleanupExpiredSessions();
    }, this.cleanupInterval);
  }

  cleanupExpiredSessions() {
    const now = new Date();
    let cleanedCount = 0;
    
    for (const [sessionId, session] of this.sessions) {
      if (!this.isSessionValid(session)) {
        this.destroySession(sessionId);
        cleanedCount++;
      }
    }
    
    if (cleanedCount > 0) {
      console.log(`Cleaned up ${cleanedCount} expired sessions`);
    }
  }

  getStats() {
    return {
      totalSessions: this.sessions.size,
      activeUsers: this.userSessions.size,
      averageSessionsPerUser: this.sessions.size / Math.max(this.userSessions.size, 1)
    };
  }
}

module.exports = UserSessionStore;

Caching Strategies

Multi-Level Cache

javascript
// cache-manager.js
const NodeCache = require('node-cache');
const redis = require('redis');

class CacheManager {
  constructor(options = {}) {
    // L1 Cache - In-memory (fastest)
    this.l1Cache = new NodeCache({
      stdTTL: options.l1TTL || 300, // 5 minutes
      checkperiod: options.l1CheckPeriod || 60 // 1 minute
    });

    // L2 Cache - Redis (shared across instances)
    this.l2Cache = redis.createClient(options.redis || {});
    this.l2TTL = options.l2TTL || 3600; // 1 hour

    this.stats = {
      l1Hits: 0,
      l2Hits: 0,
      misses: 0,
      sets: 0
    };

    this.setupEventHandlers();
  }

  setupEventHandlers() {
    this.l1Cache.on('set', (key, value) => {
      console.log(`L1 Cache SET: ${key}`);
    });

    this.l1Cache.on('del', (key, value) => {
      console.log(`L1 Cache DEL: ${key}`);
    });

    this.l1Cache.on('expired', (key, value) => {
      console.log(`L1 Cache EXPIRED: ${key}`);
    });
  }

  async get(key) {
    try {
      // Try L1 cache first
      const l1Value = this.l1Cache.get(key);
      if (l1Value !== undefined) {
        this.stats.l1Hits++;
        return l1Value;
      }

      // Try L2 cache
      const l2Value = await this.l2Cache.get(key);
      if (l2Value !== null) {
        this.stats.l2Hits++;
        
        // Promote to L1 cache
        const parsedValue = JSON.parse(l2Value);
        this.l1Cache.set(key, parsedValue);
        
        return parsedValue;
      }

      // Cache miss
      this.stats.misses++;
      return null;
    } catch (error) {
      console.error('Cache get error:', error);
      return null;
    }
  }

  async set(key, value, ttl = null) {
    try {
      this.stats.sets++;
      
      // Set in L1 cache
      this.l1Cache.set(key, value, ttl || this.l1Cache.options.stdTTL);
      
      // Set in L2 cache
      const serializedValue = JSON.stringify(value);
      await this.l2Cache.setex(key, ttl || this.l2TTL, serializedValue);
      
      return true;
    } catch (error) {
      console.error('Cache set error:', error);
      return false;
    }
  }

  async delete(key) {
    try {
      // Delete from both caches
      this.l1Cache.del(key);
      await this.l2Cache.del(key);
      return true;
    } catch (error) {
      console.error('Cache delete error:', error);
      return false;
    }
  }

  async clear() {
    try {
      this.l1Cache.flushAll();
      await this.l2Cache.flushall();
      return true;
    } catch (error) {
      console.error('Cache clear error:', error);
      return false;
    }
  }

  // Cache-aside pattern
  async getOrSet(key, fetchFunction, ttl = null) {
    const cachedValue = await this.get(key);
    
    if (cachedValue !== null) {
      return cachedValue;
    }

    try {
      const freshValue = await fetchFunction();
      await this.set(key, freshValue, ttl);
      return freshValue;
    } catch (error) {
      console.error('Cache getOrSet error:', error);
      throw error;
    }
  }

  // Write-through pattern
  async setAndPersist(key, value, persistFunction, ttl = null) {
    try {
      // Persist to database first
      await persistFunction(value);
      
      // Then cache
      await this.set(key, value, ttl);
      
      return true;
    } catch (error) {
      console.error('Cache setAndPersist error:', error);
      throw error;
    }
  }

  // Write-behind pattern
  async setWithDelayedPersist(key, value, persistFunction, ttl = null, delay = 5000) {
    try {
      // Cache immediately
      await this.set(key, value, ttl);
      
      // Persist after delay
      setTimeout(async () => {
        try {
          await persistFunction(value);
        } catch (error) {
          console.error('Delayed persist error:', error);
        }
      }, delay);
      
      return true;
    } catch (error) {
      console.error('Cache setWithDelayedPersist error:', error);
      throw error;
    }
  }

  getStats() {
    const total = this.stats.l1Hits + this.stats.l2Hits + this.stats.misses;
    
    return {
      ...this.stats,
      total,
      l1HitRate: total > 0 ? (this.stats.l1Hits / total * 100).toFixed(2) + '%' : '0%',
      l2HitRate: total > 0 ? (this.stats.l2Hits / total * 100).toFixed(2) + '%' : '0%',
      missRate: total > 0 ? (this.stats.misses / total * 100).toFixed(2) + '%' : '0%',
      l1Size: this.l1Cache.getStats().keys,
      l1Memory: this.l1Cache.getStats().vsize
    };
  }

  resetStats() {
    this.stats = {
      l1Hits: 0,
      l2Hits: 0,
      misses: 0,
      sets: 0
    };
  }
}

module.exports = CacheManager;

Cache Decorators

javascript
// cache-decorators.js
const CacheManager = require('./cache-manager');

class CacheDecorators {
  constructor(cacheManager) {
    this.cache = cacheManager;
  }

  // Method decorator for caching
  cached(ttl = 3600, keyGenerator = null) {
    return (target, propertyName, descriptor) => {
      const originalMethod = descriptor.value;

      descriptor.value = async function(...args) {
        const cacheKey = keyGenerator 
          ? keyGenerator(propertyName, args)
          : `${target.constructor.name}:${propertyName}:${JSON.stringify(args)}`;

        // Try to get from cache
        const cachedResult = await this.cache.get(cacheKey);
        if (cachedResult !== null) {
          return cachedResult;
        }

        // Execute original method
        const result = await originalMethod.apply(this, args);
        
        // Cache the result
        await this.cache.set(cacheKey, result, ttl);
        
        return result;
      };

      return descriptor;
    };
  }

  // Cache invalidation decorator
  invalidatesCache(patterns = []) {
    return (target, propertyName, descriptor) => {
      const originalMethod = descriptor.value;

      descriptor.value = async function(...args) {
        const result = await originalMethod.apply(this, args);
        
        // Invalidate cache patterns
        for (const pattern of patterns) {
          const keys = typeof pattern === 'function' 
            ? pattern(args, result)
            : [pattern];
          
          for (const key of keys) {
            await this.cache.delete(key);
          }
        }
        
        return result;
      };

      return descriptor;
    };
  }
}

// Usage example
class UserService {
  constructor(cacheManager) {
    this.cache = cacheManager;
    this.decorators = new CacheDecorators(cacheManager);
  }

  // @cached(3600) // Cache for 1 hour
  async getUserById(userId) {
    // Simulate database call
    console.log(`Fetching user ${userId} from database`);
    return {
      id: userId,
      name: `User ${userId}`,
      email: `user${userId}@example.com`
    };
  }

  // @invalidatesCache(['user:*'])
  async updateUser(userId, userData) {
    // Simulate database update
    console.log(`Updating user ${userId} in database`);
    
    // Invalidate user cache
    await this.cache.delete(`UserService:getUserById:["${userId}"]`);
    
    return { id: userId, ...userData };
  }
}

module.exports = { CacheDecorators, UserService };

State Synchronization

Event-Driven State Sync

javascript
// state-sync.js
const EventEmitter = require('events');

class StateSynchronizer extends EventEmitter {
  constructor() {
    super();
    this.nodes = new Map();
    this.state = new Map();
    this.version = 0;
  }

  registerNode(nodeId, connection) {
    this.nodes.set(nodeId, {
      id: nodeId,
      connection,
      lastSync: new Date(),
      version: 0
    });

    // Send current state to new node
    this.syncNode(nodeId);
    
    this.emit('nodeRegistered', nodeId);
  }

  unregisterNode(nodeId) {
    this.nodes.delete(nodeId);
    this.emit('nodeUnregistered', nodeId);
  }

  setState(key, value, sourceNode = null) {
    const oldValue = this.state.get(key);
    this.state.set(key, value);
    this.version++;

    const change = {
      key,
      value,
      oldValue,
      version: this.version,
      timestamp: new Date(),
      sourceNode
    };

    // Broadcast to all nodes except source
    this.broadcastChange(change, sourceNode);
    
    this.emit('stateChanged', change);
  }

  getState(key) {
    return this.state.get(key);
  }

  getAllState() {
    return Object.fromEntries(this.state);
  }

  broadcastChange(change, excludeNode = null) {
    for (const [nodeId, node] of this.nodes) {
      if (nodeId !== excludeNode) {
        try {
          node.connection.send(JSON.stringify({
            type: 'stateChange',
            data: change
          }));
        } catch (error) {
          console.error(`Failed to send to node ${nodeId}:`, error);
          this.unregisterNode(nodeId);
        }
      }
    }
  }

  syncNode(nodeId) {
    const node = this.nodes.get(nodeId);
    if (!node) return;

    const syncData = {
      type: 'fullSync',
      data: {
        state: this.getAllState(),
        version: this.version
      }
    };

    try {
      node.connection.send(JSON.stringify(syncData));
      node.lastSync = new Date();
      node.version = this.version;
    } catch (error) {
      console.error(`Failed to sync node ${nodeId}:`, error);
      this.unregisterNode(nodeId);
    }
  }

  handleMessage(nodeId, message) {
    try {
      const { type, data } = JSON.parse(message);

      switch (type) {
        case 'stateChange':
          this.handleStateChange(nodeId, data);
          break;
        case 'syncRequest':
          this.syncNode(nodeId);
          break;
        case 'heartbeat':
          this.handleHeartbeat(nodeId);
          break;
      }
    } catch (error) {
      console.error(`Invalid message from node ${nodeId}:`, error);
    }
  }

  handleStateChange(nodeId, change) {
    if (change.version > this.version) {
      this.state.set(change.key, change.value);
      this.version = change.version;
      
      // Broadcast to other nodes
      this.broadcastChange(change, nodeId);
      
      this.emit('stateChanged', { ...change, sourceNode: nodeId });
    }
  }

  handleHeartbeat(nodeId) {
    const node = this.nodes.get(nodeId);
    if (node) {
      node.lastSync = new Date();
    }
  }

  startHeartbeat(interval = 30000) {
    setInterval(() => {
      const now = new Date();
      
      for (const [nodeId, node] of this.nodes) {
        const timeSinceSync = now - node.lastSync;
        
        if (timeSinceSync > interval * 2) {
          console.log(`Node ${nodeId} appears to be disconnected`);
          this.unregisterNode(nodeId);
        }
      }
    }, interval);
  }

  getNodeStats() {
    return {
      totalNodes: this.nodes.size,
      stateSize: this.state.size,
      version: this.version,
      nodes: Array.from(this.nodes.entries()).map(([id, node]) => ({
        id,
        lastSync: node.lastSync,
        version: node.version
      }))
    };
  }
}

module.exports = StateSynchronizer;

Next Steps

In the next chapter, we'll explore functions and methods in Node.js, including advanced function patterns, closures, and functional programming concepts.

Practice Exercises

  1. Implement a distributed session store using Redis
  2. Create a multi-level caching system with automatic cache warming
  3. Build a real-time state synchronization system using WebSockets
  4. Implement a cache-aside pattern with automatic invalidation

Key Takeaways

  • Session management enables stateful interactions in stateless HTTP
  • In-memory state management provides fast access to application data
  • Multi-level caching improves performance and reduces database load
  • State synchronization enables distributed application architectures
  • Proper cache invalidation strategies prevent stale data issues
  • Event-driven state management enables reactive applications
  • Monitoring and statistics help optimize state management performance

Content is for learning and research only.