Architecting Real-Time: A Technical Deep-Dive into QuizNight's WebSocket Architecture
In my previous post, I shared the high-level journey of building QuizNight as a technology exploration project. Today, I want to dive deep into the technical architecture—specifically, how we solved the real-time synchronization challenge that makes QuizNight feel like a native, responsive application rather than a traditional web app.
The Technical Challenge
The core requirement is deceptively simple: when a quiz master advances to the next question, all participants need to see it instantly. When someone submits an answer, scores need to update in real-time across all devices. But achieving this at scale requires careful architectural decisions.
Architecture Evolution
Starting Point: HTTP Polling
The initial prototype used HTTP polling—the client would ask the server every 1-2 seconds: "Has anything changed?" It worked for a handful of users, but the math was brutal:
100 quiz masters × 50 participants × 2 requests/second = 10,000 requests/second
That's 10,000 HTTP requests per second, most of which return "nothing changed." Each request involves:
- Network round-trip latency
- HTTP overhead (headers, connection setup)
- Database queries to check for updates
- Server processing time
Even with efficient caching, this approach doesn't scale. The user experience suffers too—there's always a 1-2 second delay before participants see updates.
The Solution: WebSocket Implementation
WebSockets solve this by establishing a persistent, bidirectional connection between client and server. Instead of thousands of polling requests, we maintain ~100 persistent connections that only transmit data when something actually changes.
The performance improvement is dramatic:
- Before: 10,000 requests/second
- After: ~100 persistent connections
- Latency: Reduced from 1-2 seconds to <100ms data-preserve-html-node="true"
- Server load: Dramatically reduced
WebSocket Implementation Deep-Dive
Server-Side Architecture
The WebSocket service is implemented in server/src/websocket.ts as a class that wraps Socket.IO:
export class WebSocketService {
private io: SocketIOServer;
private sessionRooms: Map<string, Set<string>> = new Map(); // sessionId -> Set of socketIds
private socketSessions: Map<string, SessionInfo> = new Map(); // socketId -> SessionInfo
private rateLimits: Map<string, RateLimitInfo> = new Map(); // socketId -> RateLimitInfo
constructor(httpServer: HTTPServer) {
this.io = new SocketIOServer(httpServer, {
cors: {
origin: (origin, callback) => {
const allowed = [
'http://localhost:3000',
'https://www.quizznight.com',
];
if (!origin || allowed.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Not allowed by CORS'));
},
methods: ['GET', 'POST'],
credentials: true,
},
path: '/socket.io',
});
this.setupEventHandlers();
}
}
The service maintains three key data structures:
- sessionRooms: Maps session IDs to sets of connected socket IDs (for room-based broadcasting)
- socketSessions: Tracks which session each socket belongs to and their role
- rateLimits: Enforces rate limiting per socket connection
Room-Based Session Management
Socket.IO's room feature is perfect for quiz sessions. Each quiz session becomes a room, and events are broadcast only to sockets in that room:
private async handleJoinSession(socket: Socket, sessionId: string) {
// Validate session exists
const { data: session } = await supabase
.from('quiz_sessions')
.select('*')
.eq('id', sessionId)
.single();
if (!session) {
throw new Error('Session not found');
}
// Join the Socket.IO room
socket.join(`session-${sessionId}`);
// Track this socket's session
this.sessionRooms.set(sessionId,
(this.sessionRooms.get(sessionId) || new Set()).add(socket.id)
);
// Send current session state
const state = await this.getSessionState(sessionId);
socket.emit('session-state-changed', state);
}
When a quiz master advances a question, we broadcast only to that session's room:
private async handleNextQuestion(socket: Socket, sessionId: string) {
// Verify quiz master permissions
const sessionInfo = this.socketSessions.get(socket.id);
if (sessionInfo?.role !== 'quiz-master') {
throw new Error('Only quiz masters can control quiz flow');
}
// Advance question using progression engine
const newState = await progressionEngine.nextQuestion(sessionId);
// Broadcast to all sockets in this session's room
this.io.to(`session-${sessionId}`).emit('session-state-changed', newState);
this.io.to(`session-${sessionId}`).emit('question-changed', newState.currentQuestion);
}
This targeted broadcasting is crucial for performance—we're not sending updates to thousands of unrelated sockets.
Event-Driven Communication Patterns
The architecture uses a clear event-driven pattern. Client-to-server events include:
join-session: Join a quiz sessionparticipant-join: Register as a participantsubmit-answer: Submit an answer to a questionstart-quiz: Start the quiz (quiz master only)next-question: Advance to next question (quiz master only)show-answer: Reveal correct answer (quiz master only)
Server-to-client events include:
session-state-changed: Session state updatesparticipant-joined: New participant notificationparticipants-updated: Updated participant listquiz-started: Quiz has startedquestion-changed: New question displayedanswer-revealed: Correct answer shownscores-updated: Updated leaderboarderror: Error messages
This event-driven approach maps naturally to quiz interactions and makes the codebase easier to reason about.
Rate Limiting
To prevent abuse, we implement rate limiting at the socket level:
private checkRateLimit(socket: Socket): boolean {
const now = Date.now();
const limitInfo = this.rateLimits.get(socket.id) || {
lastRequest: now,
requestCount: 0,
};
// Reset counter if a minute has passed
if (now - limitInfo.lastRequest > 60000) {
limitInfo.requestCount = 0;
limitInfo.lastRequest = now;
}
// Check if limit exceeded (100 requests per minute)
if (limitInfo.requestCount >= 100) {
return false;
}
limitInfo.requestCount++;
this.rateLimits.set(socket.id, limitInfo);
return true;
}
This prevents a single malicious client from overwhelming the server while allowing legitimate high-frequency updates.
Input Validation
All WebSocket events are validated before processing:
private validateParticipantJoin(data: any): void {
if (!data.sessionId || typeof data.sessionId !== 'string' || data.sessionId.length > 100) {
throw new Error('Invalid session ID');
}
if (!data.name || typeof data.name !== 'string' || data.name.length < 1 || data.name.length > 50) {
throw new Error('Name must be 1-50 characters');
}
if (data.team && (typeof data.team !== 'string' || data.team.length > 50)) {
throw new Error('Team name must be ≤50 characters');
}
}
This validation happens before any database queries or state changes, preventing invalid data from corrupting the system.
Error Handling
Comprehensive error handling ensures graceful degradation:
private handleError(socket: Socket, error: Error, context: string): void {
console.error(`❌ Error in ${context}:`, error);
socket.emit('error', {
message: error.message,
code: this.getErrorCode(error),
});
}
Errors are logged server-side and sent to clients with user-friendly messages. Network interruptions are handled by Socket.IO's built-in reconnection logic.
Database Architecture
Supabase PostgreSQL Setup
QuizNight uses Supabase (PostgreSQL) for persistent storage. The schema includes:
- quizzes: Quiz definitions with metadata
- rounds: Quiz rounds containing multiple questions
- questions: Individual questions with options and correct answers
- quiz_sessions: Active quiz sessions
- session_states: Current state of each session (round, question, status)
- participants: Participants in active sessions
- answers: Submitted answers with timestamps
- quiz_masters: Quiz master accounts linked to auth users
Session State Management
Session state is stored in the database but also cached in memory for fast access:
private async getSessionState(sessionId: string): Promise<SessionState> {
// Try to get from progression engine cache first
const cached = progressionEngine.getCachedState(sessionId);
if (cached) return cached;
// Otherwise fetch from database
const { data: state } = await supabase
.from('session_states')
.select('*')
.eq('session_id', sessionId)
.single();
return this.normalizeSessionState(state);
}
This hybrid approach balances consistency (database is source of truth) with performance (memory cache for frequent reads).
Real-Time Subscriptions vs WebSocket Hybrid
Supabase offers real-time subscriptions, but we use WebSockets for quiz events because:
- Lower latency: WebSockets are faster than Supabase's real-time layer
- More control: We can implement custom logic, rate limiting, and error handling
- Better for high-frequency updates: Quiz events happen frequently during active sessions
We still use Supabase real-time subscriptions for less frequent updates like quiz master dashboard changes.
AI Integration
Anthropic Claude API Integration
AI question generation integrates with the WebSocket architecture:
// When quiz master requests AI generation
socket.on('generate-questions', async data => {
const { sessionId, subject, difficulty, count } = data;
// Generate questions via Claude API
const questions = await aiService.generateQuestions({
subject,
difficulty,
count,
});
// Store in database
await supabase.from('questions').insert(questions);
// Notify quiz master
socket.emit('questions-generated', questions);
});
The AI service handles prompt engineering, API calls, error handling, and cost optimization.
Question Generation Pipeline
The pipeline includes:
- Request validation: Ensure subject, difficulty, and count are valid
- Cost optimization: Check for reusable questions before generating new ones
- API call: Generate questions via Claude API with structured prompts
- Response parsing: Extract questions, options, correct answers, and explanations
- Database storage: Store questions with metadata for reuse
- Frontend update: Send generated questions to quiz master
Cost Optimization
To manage API costs, we implement a question reuse system:
// Check for existing questions first
const { data: existing } = await supabase
.from('ai_question_templates')
.select('*')
.eq('subject', subject)
.eq('difficulty', difficulty)
.or(`last_used_at.is.null,last_used_at.lt.${freshnessDate.toISOString()}`)
.limit(count);
if (existing.length >= count) {
return existing; // Reuse existing questions
}
// Otherwise generate new ones
const newQuestions = await generateViaAPI(...);
This reduces API calls while maintaining question freshness.
Model Migration
When Claude Sonnet 4 (claude-sonnet-4-20250514) became available, we migrated by:
- Updating the API client to use the new model
- Adjusting prompts for the new model's characteristics
- Testing question quality and format consistency
- Gradually rolling out to production
The migration was smooth because we'd abstracted the AI service behind a clean interface.
Authentication & Security
Google OAuth via Supabase Auth
Authentication uses Supabase Auth with Google OAuth:
// Client-side
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
// Server-side: Verify auth token
const {
data: { user },
} = await supabase.auth.getUser(token);
This provides secure authentication without managing OAuth flows manually.
Role-Based Access Control
The WebSocket service enforces role-based access:
private async handleStartQuiz(socket: Socket, sessionId: string): Promise<void> {
const sessionInfo = this.socketSessions.get(socket.id);
if (sessionInfo?.role !== 'quiz-master') {
throw new Error('Only quiz masters can start quizzes');
}
// Verify quiz master owns this session
const { data: session } = await supabase
.from('quiz_sessions')
.select('quiz_master_id')
.eq('id', sessionId)
.single();
if (session.quiz_master_id !== sessionInfo.quizMasterId) {
throw new Error('Unauthorized');
}
// Proceed with starting quiz
await progressionEngine.startQuiz(sessionId);
}
This ensures participants can't control quiz flow, and quiz masters can only control their own sessions.
Session Isolation
Multi-tenant isolation is enforced at multiple levels:
- Database: Row Level Security (RLS) policies ensure quiz masters only see their own data
- WebSocket: Room-based messaging prevents cross-session data leakage
- Application: All queries filter by quiz master ID
This allows hundreds of quiz masters to run concurrent sessions without interference.
Performance Optimizations
Memory Management
The WebSocket service carefully manages memory:
socket.on('disconnect', () => {
// Clean up session tracking
const sessionInfo = this.socketSessions.get(socket.id);
if (sessionInfo) {
const room = this.sessionRooms.get(sessionInfo.sessionId);
if (room) {
room.delete(socket.id);
if (room.size === 0) {
this.sessionRooms.delete(sessionInfo.sessionId);
}
}
this.socketSessions.delete(socket.id);
}
// Clean up rate limiting
this.rateLimits.delete(socket.id);
});
This prevents memory leaks during long-running sessions with many participants.
Database Query Optimization
We optimize database queries by:
- Indexing: Proper indexes on frequently queried columns (session_id, quiz_master_id)
- Batching: Batch multiple queries when possible
- Caching: Cache session state in memory
- Selective queries: Only fetch needed columns
Connection Pooling
Supabase handles connection pooling automatically, but we configure it appropriately:
const supabase = createClient(url, key, {
db: {
schema: 'public',
},
auth: {
persistSession: false, // We handle sessions via WebSocket
},
});
Load Testing Results
Our load testing shows the architecture handles:
- 100+ concurrent connections per server instance
- 1000+ messages per second throughput
- 50+ concurrent quiz sessions with 10 clients each
- <100ms data-preserve-html-node="true" latency for real-time updates
- <100MB data-preserve-html-node="true" memory increase for 200 connections
These numbers validate the WebSocket approach and show we can scale horizontally by adding more server instances.
Deployment Architecture
Render Platform Configuration
QuizNight is deployed on Render with:
Backend Service (render.yaml):
services:
- type: web
name: quizznight-backend
runtime: node
buildCommand: cd server && npm install && npm run build
startCommand: cd server && npm start
envVars:
- key: PORT
value: 10000
- key: NODE_ENV
value: production
Frontend Static Site:
- type: web
name: quizznight-frontend
runtime: static
buildCommand: cd client && npm install && npm run build
staticPublishPath: ./client/dist
routes:
- type: rewrite
source: /*
destination: /index.html
Environment Variable Management
Environment variables are configured differently for frontend (build-time) and backend (runtime):
Backend (runtime):
SUPABASE_URLSUPABASE_SERVICE_ROLE_KEYANTHROPIC_API_KEYPORT
Frontend (build-time, prefixed with VITE_):
VITE_API_URLVITE_WS_URLVITE_SUPABASE_URLVITE_SUPABASE_ANON_KEY
This distinction is crucial—frontend variables are baked into the build, while backend variables are available at runtime.
Code Quality & Maintainability
Modular Progression Engine
The progression engine was refactored into focused modules:
progression/
├── ProgressionEngineRefactored.ts # Main orchestrator
├── handlers/
│ ├── StartQuizHandler.ts
│ ├── NextQuestionHandler.ts
│ └── ShowAnswerHandler.ts
├── utils/
│ ├── StateManager.ts
│ └── Validation.ts
└── types/
└── SessionState.ts
This modular structure makes the codebase easier to test, debug, and extend.
TypeScript for Type Safety
TypeScript is used throughout for type safety:
export interface SessionState {
sessionId: string;
currentRound: number;
currentQuestion: number;
status: 'waiting' | 'active' | 'paused' | 'ended';
currentQuestionData?: Question;
participants: Participant[];
scores: Score[];
}
This catches errors at compile time and provides excellent IDE support.
Testing Strategy
We use a multi-layered testing approach:
- Jest for backend unit tests
- Vitest for frontend unit tests
- React Testing Library for component tests
- WebSocket integration tests for real-time behavior
This comprehensive testing catches regressions early and gives confidence when refactoring.
Scaling Considerations
Horizontal Scaling
The current architecture supports horizontal scaling:
- Stateless WebSocket connections: Each server instance can handle connections independently
- Database as source of truth: All instances read from the same database
- Room-based messaging: Works across instances with Redis adapter (future enhancement)
Future Enhancements
Planned improvements include:
- Redis adapter: Share WebSocket state across server instances
- Load balancing: Distribute connections across instances
- CDN: Serve static assets closer to users
- Database read replicas: Distribute read load
These enhancements will support thousands of concurrent quiz masters and tens of thousands of participants.
Conclusion
The WebSocket architecture is the foundation that makes QuizNight feel responsive and scalable. By moving from HTTP polling to WebSockets, we reduced server load by 99% while improving latency by 95%. The room-based session management, event-driven patterns, and careful error handling create a robust real-time system.
The technical decisions—choosing Socket.IO, implementing rate limiting, optimizing database queries, and structuring the codebase modularly—all contribute to a system that's both performant and maintainable.
In the next post, I'll explore the user experience side: how these technical capabilities translate into engaging gameplay features, and where QuizzNight is headed next.
This is the second in a series of blog posts about building QuizzNight. Next up: gameplay features and the future vision.