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:
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
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
Collaborative Edit Locks 🔒
- Temporary "I'm editing this" locks
- Auto-expire after 30 seconds
- Prevent simultaneous edit conflicts
- Why Redis: Distributed locks with TTL
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
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:
Upstash (Recommended)
- Serverless Redis
- Pay per request
- Global edge network
- HTTP/REST API (easy)
- ~$10/month for starter
Redis Cloud (Enterprise)
- Fully managed
- Better for high volume
- More features
- ~$50/month
Self-Hosted
- Full control
- Requires DevOps
- Free (except server cost)
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:
- ✅ Core platform is stable
- ✅ Have > 10 concurrent users
- ✅ Users request real-time collaboration
- ✅ Budget allows $10-50/month
Don't add Redis if:
- ❌ Still in MVP/beta
- ❌ < 10 users
- ❌ No demand for real-time features
- ❌ Want to minimize costs
Summary
- Current: No Redis needed - BroadcastChannel + MongoDB Change Streams is superior
- Phase 2: Add Redis for ephemeral real-time data (presence, cursors, locks)
- Provider: Upstash (serverless, pay-per-use)
- Cost: $10-50/month depending on usage
- Timeline: 6-9 weeks after core platform complete
Documentation References:
- Analysis:
docs/english/GoGrab-Space/architecture/sync-architecture/REDIS-VS-CURRENT-ANALYSIS.md - Research:
docs/english/GoGrab-Space/architecture/sync-architecture/RESEARCH-FINDINGS.md - Current Solution:
docs/english/GoGrab-Space/architecture/sync-architecture/ELEGANT-SYNC-SOLUTION.md