Redis Future Implementation Plan

When and how to add Redis for advanced real-time collaboration features

Redis Future Implementation Plan

Why Not Redis Now?

Current Solution is Superior for our use case:

Feature Current (No Redis) With Redis
Cross-tab sync BroadcastChannel (< 1ms) ✅ Redis pub/sub (~10ms) ❌
Multi-user sync MongoDB Change Streams (< 100ms) ✅ Redis pub/sub (~10ms) ⚠️
Data persistence MongoDB (durable) ✅ Redis (volatile) ❌
Offline support Native ✅ Requires connection ❌
Cost $0 extra ✅ ~$10-50/month ❌
Complexity Low ✅ High (extra service) ❌

When to Add Redis (Phase 2)

Trigger: Advanced Real-Time Collaboration Features

Add Redis when implementing:

  1. Real-Time Presence System 🟢

    • Show which users are currently online
    • Display "User X is editing Widget Y"
    • Auto-cleanup when users disconnect
    • Why Redis: Ephemeral data with TTL, atomic operations
  2. Live Cursor Tracking 🖱️

    • See other users' cursors moving (Google Docs style)
    • 60+ position updates per second
    • No need to persist cursor positions
    • Why Redis: Very high frequency, temporary data
  3. Collaborative Edit Locks 🔒

    • Temporary "I'm editing this" locks
    • Auto-expire after 30 seconds
    • Prevent simultaneous edit conflicts
    • Why Redis: Distributed locks with TTL
  4. Live Activity Feed 📢

    • Real-time notifications: "User X created Universe Y"
    • Don't need to persist all notifications
    • Show last 100 activities
    • Why Redis: Sorted sets, fast inserts, auto-trimming
  5. Rate Limiting ⚡

    • Limit API calls per user/IP
    • Prevent abuse
    • Time-window based throttling
    • Why Redis: Atomic counters, TTL

Implementation Steps

Step 1: Choose Redis Provider

Options:

Recommendation: Start with Upstash for simplicity

Step 2: Add Configuration

// environments/environment.ts
export const environment = {
  // ... existing config ...

  // Redis Configuration (Phase 2: Real-time Collaboration)
  redis: {
    // Upstash Redis REST API
    restApiUrl: process.env['REDIS_REST_URL'] || '',
    token: process.env['REDIS_TOKEN'] || '',

    // Feature flags
    features: {
      presence: true,      // User presence tracking
      cursors: true,       // Live cursor positions
      locks: true,         // Edit locks
      activityFeed: true,  // Real-time activity
      rateLimiting: true   // API rate limiting
    },

    // TTL defaults (seconds)
    ttl: {
      presence: 30,        // User goes "offline" after 30s
      cursor: 5,           // Cursor positions expire after 5s
      lock: 30,            // Edit locks expire after 30s
      activity: 3600,      // Activities expire after 1 hour
      rateLimit: 60        // Rate limit windows (1 minute)
    }
  }
};

Step 3: Create Redis Service

// libs/libs-foundational/core-angular/src/lib/services/redis.service.ts

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../../../apps/gograb-space/src/environments/environment';

interface RedisCommand {
  command: string;
  args: any[];
}

@Injectable({
  providedIn: 'root'
})
export class RedisService {
  private http = inject(HttpClient);
  private baseUrl = environment.redis?.restApiUrl;
  private token = environment.redis?.token;

  /**
   * Execute Redis command via REST API
   */
  private async execute<T = any>(command: string, ...args: any[]): Promise<T> {
    if (!this.baseUrl || !this.token) {
      throw new Error('Redis not configured');
    }

    const response = await fetch(this.baseUrl, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify([command, ...args])
    });

    const data = await response.json();
    return data.result;
  }

  // ==========================================
  // PRESENCE SYSTEM
  // ==========================================

  /**
   * Mark user as online
   */
  async setUserOnline(userId: string, metadata?: any): Promise<void> {
    const key = `presence:${userId}`;
    const ttl = environment.redis?.ttl.presence || 30;

    await this.execute('SETEX', key, ttl, JSON.stringify({
      userId,
      timestamp: Date.now(),
      ...metadata
    }));
  }

  /**
   * Get all online users
   */
  async getOnlineUsers(): Promise<string[]> {
    const keys = await this.execute<string[]>('KEYS', 'presence:*');
    return keys.map(k => k.replace('presence:', ''));
  }

  /**
   * Mark user as editing specific widget
   */
  async setUserEditing(userId: string, widgetId: string): Promise<void> {
    const key = `editing:${widgetId}`;
    const ttl = environment.redis?.ttl.lock || 30;

    await this.execute('SETEX', key, ttl, userId);
  }

  /**
   * Get who is editing widget
   */
  async getWhoIsEditing(widgetId: string): Promise<string | null> {
    return this.execute('GET', `editing:${widgetId}`);
  }

  // ==========================================
  // LIVE CURSORS
  // ==========================================

  /**
   * Update user's cursor position
   */
  async updateCursorPosition(
    userId: string,
    universeId: string,
    position: { x: number; y: number }
  ): Promise<void> {
    const key = `cursor:${universeId}:${userId}`;
    const ttl = environment.redis?.ttl.cursor || 5;

    await this.execute('SETEX', key, ttl, JSON.stringify(position));
  }

  /**
   * Get all cursor positions in universe
   */
  async getCursorPositions(universeId: string): Promise<Map<string, any>> {
    const keys = await this.execute<string[]>('KEYS', `cursor:${universeId}:*`);
    const positions = new Map();

    for (const key of keys) {
      const userId = key.split(':')[2];
      const data = await this.execute('GET', key);
      if (data) {
        positions.set(userId, JSON.parse(data));
      }
    }

    return positions;
  }

  // ==========================================
  // EDIT LOCKS
  // ==========================================

  /**
   * Try to acquire edit lock
   */
  async acquireLock(
    resourceId: string,
    userId: string,
    ttl: number = 30
  ): Promise<boolean> {
    const key = `lock:${resourceId}`;

    // NX = only set if doesn't exist (atomic)
    const result = await this.execute('SET', key, userId, 'EX', ttl, 'NX');
    return result === 'OK';
  }

  /**
   * Release edit lock
   */
  async releaseLock(resourceId: string, userId: string): Promise<boolean> {
    const key = `lock:${resourceId}`;
    const currentOwner = await this.execute('GET', key);

    if (currentOwner === userId) {
      await this.execute('DEL', key);
      return true;
    }

    return false;
  }

  // ==========================================
  // ACTIVITY FEED
  // ==========================================

  /**
   * Add activity to feed
   */
  async addActivity(
    spaceId: string,
    activity: {
      userId: string;
      action: string;
      resourceId: string;
      timestamp?: number;
    }
  ): Promise<void> {
    const key = `activity:${spaceId}`;
    const score = activity.timestamp || Date.now();

    await this.execute('ZADD', key, score, JSON.stringify(activity));

    // Keep only last 100 activities
    await this.execute('ZREMRANGEBYRANK', key, 0, -101);
  }

  /**
   * Get recent activities
   */
  async getRecentActivities(spaceId: string, limit: number = 20): Promise<any[]> {
    const key = `activity:${spaceId}`;
    const activities = await this.execute<string[]>(
      'ZREVRANGE', key, 0, limit - 1
    );

    return activities.map(a => JSON.parse(a));
  }

  // ==========================================
  // RATE LIMITING
  // ==========================================

  /**
   * Check if action is rate limited
   */
  async isRateLimited(
    key: string,
    maxRequests: number,
    windowSeconds: number
  ): Promise<boolean> {
    const current = await this.execute<number>('INCR', `rate:${key}`);

    if (current === 1) {
      // First request, set expiry
      await this.execute('EXPIRE', `rate:${key}`, windowSeconds);
    }

    return current > maxRequests;
  }

  /**
   * Get remaining requests
   */
  async getRateLimitInfo(key: string): Promise<{
    current: number;
    ttl: number;
  }> {
    const [current, ttl] = await Promise.all([
      this.execute<number>('GET', `rate:${key}`).then(v => v || 0),
      this.execute<number>('TTL', `rate:${key}`)
    ]);

    return { current, ttl };
  }
}

Step 4: Integrate with Components

// Example: Real-time presence in editor
@Component({
  selector: 'app-universe-editor',
  template: `
    <div class="editor">
      <!-- Show online users -->
      <div class="presence">
        @for (user of onlineUsers(); track user.id) {
          <div class="user-avatar" [title]="user.name">
            {{ user.initials }}
            @if (user.isEditing) {
              <span class="editing-badge">✏️</span>
            }
          </div>
        }
      </div>

      <!-- Show other users' cursors -->
      @for (cursor of otherCursors(); track cursor.userId) {
        <div
          class="remote-cursor"
          [style.left.px]="cursor.x"
          [style.top.px]="cursor.y"
          [style.borderColor]="cursor.color"
        >
          <div class="cursor-label">{{ cursor.userName }}</div>
        </div>
      }
    </div>
  `
})
export class UniverseEditorComponent {
  private redis = inject(RedisService);
  private currentUser = inject(AuthService).currentUser;

  onlineUsers = signal<User[]>([]);
  otherCursors = signal<Cursor[]>([]);

  async ngOnInit() {
    // Mark self as online
    await this.redis.setUserOnline(this.currentUser().id, {
      name: this.currentUser().name
    });

    // Heartbeat every 15 seconds (TTL is 30s)
    setInterval(() => {
      this.redis.setUserOnline(this.currentUser().id);
    }, 15000);

    // Poll for online users
    setInterval(async () => {
      const userIds = await this.redis.getOnlineUsers();
      this.onlineUsers.set(userIds.map(/* fetch user data */));
    }, 5000);

    // Track cursor movements
    fromEvent<MouseEvent>(document, 'mousemove')
      .pipe(throttleTime(50)) // 20 updates per second
      .subscribe(async (event) => {
        await this.redis.updateCursorPosition(
          this.currentUser().id,
          this.universeId,
          { x: event.clientX, y: event.clientY }
        );
      });

    // Poll for other cursors
    setInterval(async () => {
      const cursors = await this.redis.getCursorPositions(this.universeId);
      this.otherCursors.set(
        Array.from(cursors.entries())
          .filter(([userId]) => userId !== this.currentUser().id)
          .map(([userId, pos]) => ({ userId, ...pos }))
      );
    }, 100); // 10 times per second
  }
}

Cost Estimation

Upstash Pricing (Recommended)

Free Tier:
- 10,000 commands/day
- Good for: Development, small teams (< 5 users)

Pro ($10/month):
- 100,000 commands/day
- Good for: Small production (< 50 concurrent users)

Team ($50/month):
- 1,000,000 commands/day
- Good for: Medium production (< 500 concurrent users)

Enterprise (Custom):
- Unlimited commands
- Good for: Large scale (1000+ concurrent users)

Command Usage Estimates

Per active user per hour:
- Presence heartbeat: 240 commands (every 15s)
- Cursor updates: 7,200 commands (20 per second)
- Activity feed: 50 commands (typical actions)
- Edit locks: 20 commands (acquire/release)
Total: ~7,500 commands/hour/user

10 concurrent users = 75,000 commands/hour = 1.8M/day
→ Would need Team plan ($50/month)

50 concurrent users = 9M commands/day
→ Would need Enterprise plan

Migration Strategy

Phase 2.1: Add Presence (No breaking changes)

Week 1-2: Implementation
├─ Add RedisService
├─ Add presence tracking
├─ Show online users
└─ Add "User X is here" indicators

Week 3: Testing & Optimization
└─ Monitor Redis usage

Phase 2.2: Add Live Cursors

Week 4-5: Implementation
├─ Add cursor tracking
├─ Render remote cursors
└─ Color coding per user

Week 6: Performance Tuning
└─ Optimize update frequency

Phase 2.3: Add Edit Locks

Week 7-8: Implementation
├─ Add lock acquisition
├─ Show "locked" indicators
└─ Handle lock conflicts

Week 9: Testing
└─ Test simultaneous edits

Decision Checkpoint

Add Redis when:

Don't add Redis if:

Summary

Documentation References: