Trade-offs & Alternatives

📖 11 min read 📄 Part 7 of 10

Team Collaboration Tool - Tradeoffs and Alternatives

Real-time Communication Tradeoffs

WebSocket vs Server-Sent Events vs Long Polling

Aspect WebSocket Server-Sent Events Long Polling
Bidirectional Yes No (server→client only) Yes
Browser Support Excellent Good Universal
Connection Overhead Low (persistent) Low (persistent) High (frequent reconnects)
Firewall Issues Some Rare None
Complexity High Medium Low
Real-time Performance Excellent Good Poor
Scalability Good Better Poor

WebSocket Implementation (Chosen)

// WebSocket for bidirectional real-time communication
class WebSocketManager {
    constructor() {
        this.connections = new Map();
        this.heartbeatInterval = 30000; // 30 seconds
    }
    
    handleConnection(socket, userId, workspaceId) {
        // Store connection with metadata
        this.connections.set(socket.id, {
            socket: socket,
            userId: userId,
            workspaceId: workspaceId,
            lastHeartbeat: Date.now()
        });
        
        // Set up heartbeat
        const heartbeat = setInterval(() => {
            socket.ping();
        }, this.heartbeatInterval);
        
        socket.on('pong', () => {
            const conn = this.connections.get(socket.id);
            if (conn) conn.lastHeartbeat = Date.now();
        });
        
        socket.on('disconnect', () => {
            clearInterval(heartbeat);
            this.connections.delete(socket.id);
        });
    }
}

Pros:

  • Full-duplex communication
  • Low latency for real-time features
  • Efficient for high-frequency updates
  • Native browser support

Cons:

  • Complex connection management
  • Firewall/proxy issues
  • Scaling challenges with connection limits
  • Stateful connections complicate load balancing

Server-Sent Events Alternative

// SSE for one-way real-time updates
class SSEManager {
    setupSSE(req, res, userId, workspaceId) {
        res.writeHead(200, {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'Access-Control-Allow-Origin': '*'
        });
        
        // Send initial connection event
        res.write(`data: ${JSON.stringify({
            type: 'connected',
            userId: userId
        })}\n\n`);
        
        // Subscribe to user's events
        this.subscribeToUserEvents(userId, workspaceId, (event) => {
            res.write(`data: ${JSON.stringify(event)}\n\n`);
        });
        
        // Handle client disconnect
        req.on('close', () => {
            this.unsubscribeFromUserEvents(userId);
        });
    }
}

When to Use SSE: Read-heavy applications, simpler architecture needs, better firewall compatibility

Database Architecture Tradeoffs

SQL vs NoSQL for Different Use Cases

Data Type PostgreSQL (SQL) MongoDB (NoSQL) Cassandra (NoSQL)
User Profiles ✅ ACID compliance ❌ Eventual consistency ❌ Limited queries
Messages ❌ Write scaling ✅ Flexible schema ✅ High write throughput
File Metadata ✅ Complex queries ✅ Rich documents ❌ Query limitations
Analytics ❌ Time series ❌ Time series ✅ Time series optimized
Search ❌ Full-text search ✅ Text search ❌ No full-text

PostgreSQL for Core Data (Chosen)

-- Strong consistency for critical operations
BEGIN TRANSACTION;
    INSERT INTO messages (channel_id, user_id, content) 
    VALUES ($1, $2, $3) RETURNING message_id;
    
    UPDATE channels 
    SET last_message_at = NOW() 
    WHERE channel_id = $1;
    
    INSERT INTO channel_activity (channel_id, activity_type, user_id)
    VALUES ($1, 'message_sent', $2);
COMMIT;

Pros: ACID compliance, complex queries, mature ecosystem, strong consistency Cons: Vertical scaling limits, complex sharding, higher operational overhead

MongoDB Alternative for Messages

// MongoDB for flexible message schema
class MongoMessageStore {
    async saveMessage(message) {
        const messageDoc = {
            _id: new ObjectId(),
            channelId: message.channelId,
            userId: message.userId,
            content: message.content,
            messageType: message.type,
            threadId: message.threadId,
            reactions: {},
            files: message.files || [],
            mentions: this.extractMentions(message.content),
            createdAt: new Date(),
            editHistory: []
        };
        
        // Atomic insert with channel update
        const session = this.client.startSession();
        
        try {
            await session.withTransaction(async () => {
                await this.messages.insertOne(messageDoc, { session });
                await this.channels.updateOne(
                    { _id: message.channelId },
                    { 
                        $set: { lastMessageAt: new Date() },
                        $inc: { messageCount: 1 }
                    },
                    { session }
                );
            });
        } finally {
            await session.endSession();
        }
        
        return messageDoc;
    }
}

When to Use MongoDB: Flexible schema needs, rapid prototyping, document-heavy data

Message Storage Strategies

Append-Only vs Mutable Message Storage

Approach Append-Only (Event Sourcing) Mutable (Traditional)
Data Integrity Excellent (immutable) Good (with backups)
Edit History Native support Requires separate table
Storage Space Higher (all events) Lower (current state)
Query Performance Slower (reconstruction) Faster (direct access)
Complexity High Low
Audit Trail Complete Limited

Event Sourcing Implementation

class EventSourcingMessageStore {
    async saveMessageEvent(event) {
        // Store immutable event
        const eventRecord = {
            eventId: this.generateEventId(),
            aggregateId: event.messageId,
            eventType: event.type, // 'message_sent', 'message_edited', 'message_deleted'
            eventData: event.data,
            userId: event.userId,
            timestamp: new Date(),
            version: await this.getNextVersion(event.messageId)
        };
        
        await this.eventStore.insert(eventRecord);
        
        // Update read model asynchronously
        await this.updateReadModel(eventRecord);
        
        return eventRecord;
    }
    
    async reconstructMessage(messageId) {
        const events = await this.eventStore.find({
            aggregateId: messageId
        }).sort({ version: 1 });
        
        let message = null;
        
        for (const event of events) {
            switch (event.eventType) {
                case 'message_sent':
                    message = event.eventData;
                    break;
                case 'message_edited':
                    message.content = event.eventData.content;
                    message.editHistory.push({
                        content: message.content,
                        editedAt: event.timestamp
                    });
                    break;
                case 'message_deleted':
                    message.isDeleted = true;
                    message.deletedAt = event.timestamp;
                    break;
            }
        }
        
        return message;
    }
}

When to Use Event Sourcing: Audit requirements, complex business logic, temporal queries

Search Architecture Alternatives

Elasticsearch vs PostgreSQL Full-Text vs Solr

Feature Elasticsearch PostgreSQL FTS Apache Solr
Performance Excellent Good Excellent
Scalability Excellent Limited Good
Real-time Near real-time Real-time Near real-time
Complexity High Low High
Maintenance High Low Medium
Features Rich Basic Rich

Elasticsearch Implementation (Chosen)

class ElasticsearchService {
    async indexMessage(message) {
        const doc = {
            message_id: message.id,
            workspace_id: message.workspaceId,
            channel_id: message.channelId,
            user_id: message.userId,
            content: message.content,
            message_type: message.type,
            created_at: message.createdAt,
            mentions: this.extractMentions(message.content),
            has_files: message.files.length > 0,
            thread_id: message.threadId
        };
        
        await this.client.index({
            index: `workspace_${message.workspaceId}`,
            id: message.id,
            body: doc
        });
    }
    
    async searchMessages(workspaceId, query, filters = {}) {
        const searchBody = {
            query: {
                bool: {
                    must: [
                        {
                            multi_match: {
                                query: query,
                                fields: ['content^2', 'filename'],
                                type: 'best_fields'
                            }
                        }
                    ],
                    filter: [
                        { term: { workspace_id: workspaceId } }
                    ]
                }
            },
            highlight: {
                fields: {
                    content: {}
                }
            },
            sort: [
                { _score: { order: 'desc' } },
                { created_at: { order: 'desc' } }
            ]
        };
        
        // Add filters
        if (filters.channelId) {
            searchBody.query.bool.filter.push({
                term: { channel_id: filters.channelId }
            });
        }
        
        if (filters.dateRange) {
            searchBody.query.bool.filter.push({
                range: {
                    created_at: {
                        gte: filters.dateRange.start,
                        lte: filters.dateRange.end
                    }
                }
            });
        }
        
        const result = await this.client.search({
            index: `workspace_${workspaceId}`,
            body: searchBody
        });
        
        return this.formatSearchResults(result);
    }
}

PostgreSQL Full-Text Search Alternative

-- PostgreSQL full-text search implementation
CREATE INDEX idx_messages_search_vector 
ON messages USING gin(to_tsvector('english', content));

-- Search query
SELECT 
    m.message_id,
    m.content,
    m.created_at,
    u.display_name,
    ts_headline('english', m.content, plainto_tsquery('english', $1)) as highlighted_content,
    ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $1)) as rank
FROM messages m
JOIN users u ON m.user_id = u.user_id
WHERE m.workspace_id = $2
    AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $1)
    AND m.is_deleted = FALSE
ORDER BY rank DESC, m.created_at DESC
LIMIT 50;

When to Use PostgreSQL FTS: Simple search needs, single database preference, lower complexity

File Storage Tradeoffs

Object Storage vs Database Storage vs Hybrid

Approach Object Storage (S3) Database (PostgreSQL) Hybrid
Scalability Unlimited Limited Good
Performance Good (CDN) Excellent (local) Variable
Cost Low High Medium
Backup Built-in Manual Mixed
Consistency Eventual Strong Mixed
Complexity Medium Low High

Object Storage Implementation (Chosen)

class ObjectStorageService {
    constructor() {
        this.s3 = new AWS.S3();
        this.cloudfront = new AWS.CloudFront();
        this.buckets = {
            hot: 'teamchat-files-hot',
            warm: 'teamchat-files-warm',
            cold: 'teamchat-files-cold'
        };
    }
    
    async uploadFile(file, workspaceId, userId) {
        const fileKey = this.generateFileKey(workspaceId, userId, file.name);
        
        // Upload to hot storage initially
        const uploadResult = await this.s3.upload({
            Bucket: this.buckets.hot,
            Key: fileKey,
            Body: file.stream,
            ContentType: file.mimeType,
            Metadata: {
                workspace_id: workspaceId,
                uploader_id: userId,
                original_name: file.name
            }
        }).promise();
        
        // Generate CDN URL for fast access
        const cdnUrl = this.cloudfront.getSignedUrl('getObject', {
            Bucket: this.buckets.hot,
            Key: fileKey,
            Expires: 3600 // 1 hour
        });
        
        return {
            fileKey: fileKey,
            storageUrl: uploadResult.Location,
            cdnUrl: cdnUrl
        };
    }
    
    async tierFile(fileKey, accessPattern) {
        const currentBucket = await this.getCurrentBucket(fileKey);
        let targetBucket;
        
        if (accessPattern.lastAccessed > 90) { // 90 days
            targetBucket = this.buckets.cold;
        } else if (accessPattern.lastAccessed > 30) { // 30 days
            targetBucket = this.buckets.warm;
        } else {
            targetBucket = this.buckets.hot;
        }
        
        if (currentBucket !== targetBucket) {
            await this.moveFile(fileKey, currentBucket, targetBucket);
        }
    }
}

Database Storage Alternative

class DatabaseFileStorage {
    async storeFile(file, workspaceId, userId) {
        // Store file as BYTEA in PostgreSQL
        const query = `
            INSERT INTO file_storage (
                file_id, workspace_id, uploader_id, 
                filename, mime_type, file_data, file_size
            ) VALUES ($1, $2, $3, $4, $5, $6, $7)
            RETURNING file_id
        `;
        
        const fileData = await this.readFileBuffer(file);
        
        const result = await this.db.query(query, [
            this.generateFileId(),
            workspaceId,
            userId,
            file.name,
            file.mimeType,
            fileData,
            fileData.length
        ]);
        
        return result.rows[0].file_id;
    }
}

When to Use Database Storage: Small files, strong consistency needs, simple architecture

Authentication Strategies

JWT vs Session-Based vs OAuth-Only

Aspect JWT Tokens Session Cookies OAuth-Only
Scalability Stateless (excellent) Stateful (limited) Depends on provider
Security Token exposure risk Server-side control Provider dependent
Performance No DB lookup DB lookup required External API calls
Revocation Complex Immediate Provider dependent
Offline Works offline Requires server Requires connectivity
Mobile Excellent Limited Good

JWT Implementation (Chosen)

class JWTAuthService {
    constructor() {
        this.accessTokenTTL = 15 * 60; // 15 minutes
        this.refreshTokenTTL = 7 * 24 * 60 * 60; // 7 days
    }
    
    async generateTokens(user, workspace) {
        const payload = {
            userId: user.id,
            workspaceId: workspace.id,
            role: user.role,
            permissions: user.permissions,
            iat: Math.floor(Date.now() / 1000)
        };
        
        const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
            expiresIn: this.accessTokenTTL
        });
        
        const refreshToken = jwt.sign(
            { userId: user.id, tokenVersion: user.tokenVersion },
            process.env.REFRESH_SECRET,
            { expiresIn: this.refreshTokenTTL }
        );
        
        // Store refresh token hash for revocation
        await this.storeRefreshToken(user.id, this.hashToken(refreshToken));
        
        return { accessToken, refreshToken };
    }
    
    async refreshAccessToken(refreshToken) {
        try {
            const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
            
            // Check if refresh token is still valid
            const isValid = await this.validateRefreshToken(
                decoded.userId, 
                this.hashToken(refreshToken)
            );
            
            if (!isValid) {
                throw new Error('Invalid refresh token');
            }
            
            // Generate new access token
            const user = await this.getUser(decoded.userId);
            const workspace = await this.getUserWorkspace(decoded.userId);
            
            return this.generateTokens(user, workspace);
        } catch (error) {
            throw new Error('Token refresh failed');
        }
    }
}

Session-Based Alternative

class SessionAuthService {
    constructor() {
        this.sessionStore = new RedisSessionStore();
        this.sessionTTL = 24 * 60 * 60; // 24 hours
    }
    
    async createSession(user, workspace) {
        const sessionId = this.generateSessionId();
        const sessionData = {
            userId: user.id,
            workspaceId: workspace.id,
            role: user.role,
            permissions: user.permissions,
            createdAt: new Date(),
            lastActivity: new Date()
        };
        
        await this.sessionStore.set(
            sessionId, 
            JSON.stringify(sessionData),
            this.sessionTTL
        );
        
        return sessionId;
    }
    
    async validateSession(sessionId) {
        const sessionData = await this.sessionStore.get(sessionId);
        
        if (!sessionData) {
            throw new Error('Invalid session');
        }
        
        const session = JSON.parse(sessionData);
        
        // Update last activity
        session.lastActivity = new Date();
        await this.sessionStore.set(
            sessionId,
            JSON.stringify(session),
            this.sessionTTL
        );
        
        return session;
    }
}

When to Use Sessions: High security requirements, immediate revocation needs, server-side control preference

Caching Strategies

Redis vs Memcached vs In-Memory

Feature Redis Memcached In-Memory (Node.js)
Data Types Rich (strings, lists, sets) Key-value only Any JavaScript type
Persistence Optional None None
Clustering Built-in Manual Process-bound
Memory Usage Higher Lower Process memory
Features Pub/Sub, Lua scripts Simple Full programming
Scalability Excellent Good Limited

Redis Implementation (Chosen)

class RedisCacheService {
    constructor() {
        this.redis = new Redis.Cluster([
            { host: 'redis-1', port: 6379 },
            { host: 'redis-2', port: 6379 },
            { host: 'redis-3', port: 6379 }
        ]);
        
        this.defaultTTL = 3600; // 1 hour
    }
    
    async cacheWithFallback(key, fetchFunction, ttl = this.defaultTTL) {
        // Try cache first
        const cached = await this.redis.get(key);
        if (cached) {
            return JSON.parse(cached);
        }
        
        // Fetch from source
        const data = await fetchFunction();
        
        // Cache the result
        await this.redis.setex(key, ttl, JSON.stringify(data));
        
        return data;
    }
    
    async invalidatePattern(pattern) {
        const keys = await this.redis.keys(pattern);
        if (keys.length > 0) {
            await this.redis.del(...keys);
        }
    }
}

Decision Matrix

Architecture Decision Framework

Decision Criteria Weights:
  Performance: 25%
  Scalability: 25%
  Reliability: 20%
  Development Speed: 15%
  Operational Complexity: 10%
  Cost: 5%

WebSocket vs SSE Decision:
  WebSocket Score: 8.5/10
    - Performance: 9/10 (bidirectional, low latency)
    - Scalability: 8/10 (connection limits)
    - Reliability: 8/10 (reconnection handling)
    - Development Speed: 7/10 (complex implementation)
    - Operational Complexity: 7/10 (stateful connections)
    - Cost: 8/10 (efficient bandwidth usage)
  
  SSE Score: 7.2/10
    - Performance: 7/10 (unidirectional)
    - Scalability: 9/10 (simpler scaling)
    - Reliability: 8/10 (HTTP-based)
    - Development Speed: 8/10 (simpler implementation)
    - Operational Complexity: 8/10 (stateless)
    - Cost: 7/10 (HTTP overhead)

Winner: WebSocket for real-time collaboration needs

Technology Selection Summary

Final Architecture Decisions:

Real-time Communication: WebSocket
  Reason: Bidirectional communication essential for collaboration

Database Strategy: PostgreSQL + Redis + Elasticsearch
  - PostgreSQL: ACID compliance for core data
  - Redis: Real-time caching and pub/sub
  - Elasticsearch: Advanced search capabilities

File Storage: Object Storage (S3) with CDN
  Reason: Scalability, cost-effectiveness, global distribution

Authentication: JWT with refresh tokens
  Reason: Stateless scaling, mobile-friendly

Search: Elasticsearch
  Reason: Advanced search features, scalability

Caching: Redis Cluster
  Reason: Rich data types, pub/sub, clustering support