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:

  1. sessionRooms: Maps session IDs to sets of connected socket IDs (for room-based broadcasting)
  2. socketSessions: Tracks which session each socket belongs to and their role
  3. 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 session
  • participant-join: Register as a participant
  • submit-answer: Submit an answer to a question
  • start-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 updates
  • participant-joined: New participant notification
  • participants-updated: Updated participant list
  • quiz-started: Quiz has started
  • question-changed: New question displayed
  • answer-revealed: Correct answer shown
  • scores-updated: Updated leaderboard
  • error: 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:

  1. Lower latency: WebSockets are faster than Supabase's real-time layer
  2. More control: We can implement custom logic, rate limiting, and error handling
  3. 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:

  1. Request validation: Ensure subject, difficulty, and count are valid
  2. Cost optimization: Check for reusable questions before generating new ones
  3. API call: Generate questions via Claude API with structured prompts
  4. Response parsing: Extract questions, options, correct answers, and explanations
  5. Database storage: Store questions with metadata for reuse
  6. 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:

  1. Updating the API client to use the new model
  2. Adjusting prompts for the new model's characteristics
  3. Testing question quality and format consistency
  4. 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:

  1. Database: Row Level Security (RLS) policies ensure quiz masters only see their own data
  2. WebSocket: Room-based messaging prevents cross-session data leakage
  3. 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:

  1. Indexing: Proper indexes on frequently queried columns (session_id, quiz_master_id)
  2. Batching: Batch multiple queries when possible
  3. Caching: Cache session state in memory
  4. 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_URL
  • SUPABASE_SERVICE_ROLE_KEY
  • ANTHROPIC_API_KEY
  • PORT

Frontend (build-time, prefixed with VITE_):

  • VITE_API_URL
  • VITE_WS_URL
  • VITE_SUPABASE_URL
  • VITE_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:

  1. Stateless WebSocket connections: Each server instance can handle connections independently
  2. Database as source of truth: All instances read from the same database
  3. Room-based messaging: Works across instances with Redis adapter (future enhancement)

Future Enhancements

Planned improvements include:

  1. Redis adapter: Share WebSocket state across server instances
  2. Load balancing: Distribute connections across instances
  3. CDN: Serve static assets closer to users
  4. 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.



Previous
Previous

Creating Engaging Quiz Experiences: Gameplay Features and the Future of QuizzNight

Next
Next

From Idea to Live: Building QuizzNight as a Technology Exploration Project