Real-Time Architecture

Eventuall is a live event platform, so real-time communication is central to the architecture. Three services handle different aspects: LiveKit for video and audio, Twilio for text chat, and PartyKit (Durable Objects with WebSockets) for presence and state synchronization.

How It All Connects

When a user joins an event room, several real-time connections are established simultaneously:

sequenceDiagram
    participant Browser
    participant NextJS as Next.js Server
    participant Workers as Cloudflare Workers
    participant LK as LiveKit
    participant TW as Twilio

    Browser->>NextJS: Join event room
    NextJS->>NextJS: Generate LiveKit token
    NextJS->>NextJS: Generate Twilio token
    Browser->>Workers: WebSocket connect (PartyKit)
    Workers->>Workers: Event DO: add user to presence
    Workers-->>Browser: Snapshot (participants, session state)
    Browser->>LK: Connect to LiveKit room
    Browser->>TW: Connect to Twilio conversation
    Note over Browser: User is now live with video, chat, and presence

Each service handles a distinct concern:

Service Responsibility Protocol State
LiveKit Video, audio, screen sharing WebRTC Managed by LiveKit Cloud
Twilio Text chat, reactions WebSocket (Twilio SDK) Managed by Twilio
PartyKit Presence, event state, queue management WebSocket (custom) Durable Object storage

LiveKit: Video and Audio

LiveKit provides the WebRTC infrastructure for video calls. The Eventuall integration follows a token-based authentication model: the server generates a signed JWT token for each user, and the client uses it to connect directly to the LiveKit room.

Token Generation

The tRPC livekit.getToken mutation generates a LiveKit access token with the user's identity, display name, and room permissions:

// Server-side token generation (apps/webapp/src/server/api/routers/livekit.ts)
const at = new AccessToken(apiKey, apiSecret, {
  identity: userId,
  name: displayName,
  metadata: JSON.stringify({ eventId, roomId }),
});

at.addGrant({
  room: `${eventId}:${roomId}`,
  roomJoin: true,
  canPublish: true,
  canSubscribe: true,
});

The room name follows the format {eventId}:{roomId}, which uniquely identifies a room within an event. The metadata payload lets LiveKit webhooks route events back to the correct Event DO.

Client Components

The LiveKit React SDK provides pre-built components in apps/webapp/src/components/livekit/:

  • EventRoomWebRTC — the main room component that manages the LiveKit connection
  • ParticipantTile — renders individual video/audio feeds
  • BreakoutRoom — handles breakout room functionality
  • VettingScreen — shows a waiting room for participants being vetted before joining the stage

Recording Pipeline

When a moderator starts recording, the CloudcasterWorkflow (see Backend Architecture) orchestrates:

  1. Creates a Mux live stream (RTMP endpoint)
  2. Starts LiveKit Egress, which composites all video feeds and pushes RTMP to Mux
  3. Mux ingests the stream and provides HLS playback

This creates a one-way bridge: LiveKit captures the room, Mux provides the recording and playback.

Architect's Note: The recording pipeline introduces a ~5-15 second delay between the live room and the recorded stream. This is inherent to RTMP-to-HLS conversion. For use cases requiring lower latency playback, Mux's low-latency mode reduces this to ~3-5 seconds, but it's enabled per-stream and uses more bandwidth.

Participant Clips

After a recording stops, the system generates individual clips for each participant. Stage events (enter/exit timestamps) are logged during the recording, and afterward the CloudcasterWorkflow creates Mux clips from these time ranges. Each clip is a separate Mux asset with its own playback URL.

Pitfall: Clip generation is asynchronous and can fail silently if the Mux asset isn't ready. The workflow polls for clip status with exponential backoff, but if the parent recording is still processing, clips will be in a "pending" state until Mux finishes processing the main asset.

Twilio: Text Chat

Twilio Conversations powers the text chat in event rooms. Each room can have a Twilio conversation attached, stored in the rooms.twilioConversationSid column.

How Chat Works

  1. The server creates a Twilio conversation via the REST API when a room needs chat
  2. Users get a session token from the twilio.getToken endpoint
  3. The Twilio JavaScript SDK connects to the conversation and handles message delivery
  4. Messages support emoji reactions through a custom toggleReaction mutation that reads/writes reaction metadata on Twilio message attributes

Vetting Conversations

In addition to room chat, there are per-user vetting conversations (eventRoomVetConversation table). These are private channels between a moderator and a participant who is waiting to be approved for the stage. The vetting flow:

  1. Participant requests to join the stage
  2. System creates a private Twilio conversation between the participant and moderators
  3. Moderators can chat with the participant in the vetting channel
  4. Once approved, the participant is moved to the live stage

PartyKit: Presence and State

PartyKit runs on Cloudflare Durable Objects and handles everything that doesn't fit into LiveKit or Twilio: user presence, event state synchronization, and queue management.

Event State Broadcasts

The Event DO maintains a "snapshot" of the current event state and broadcasts it to all connected WebSocket clients whenever something changes. A snapshot includes:

  • Participant list — who is in the room and their current state
  • Presence data — online, away, or offline status for each user
  • Stage state — which participants are live on stage
  • Pinned tracks — which video feeds are pinned
  • Recording state — whether a recording is active, its status, and playback URL
  • Queue state — the pending and live queues

When any of these change (a participant joins, a moderator pins a track, recording starts), the Event DO calls broadcastNewSnapshot() to push the update to all connected clients.

Heartbeat Protocol

Clients send heartbeat messages to the Event DO at regular intervals. The heartbeat serves two purposes:

  1. Presence detection — if no heartbeat is received within 90 seconds, the user is marked as "away"
  2. Connection liveness — helps the DO distinguish between slow connections and dropped ones

A scheduled alarm runs every 30 seconds to scan for stale connections, mark away users, and remove users who haven't been seen in 24 hours.

Queue Management with Yjs

The Queue DO uses Yjs for conflict-free queue management. Multiple moderators can add, remove, and reorder participants in the queue simultaneously. Yjs CRDTs guarantee that all clients converge to the same state, even if edits happen concurrently on different connections.

The queue has two lists:

  • Pending — participants waiting to join the stage
  • Live — participants currently on stage

Moving a participant between lists is an atomic operation in the Yjs document.

Architect's Note: Yjs adds ~15KB to the worker bundle but eliminates an entire class of concurrency bugs. Without CRDTs, you'd need server-side locking or last-write-wins semantics, both of which create poor user experiences when multiple moderators are active simultaneously.

Connection Lifecycle

Here's what happens when a user navigates to an event page and then leaves:

On join:

  1. Page loads as a Server Component, fetching event data from the database
  2. Client Components mount and request LiveKit + Twilio tokens via tRPC
  3. PartyKit WebSocket connection opens to the Event DO
  4. Event DO adds the user to its presence map and broadcasts a snapshot
  5. LiveKit and Twilio SDKs connect to their respective services
  6. User sees video, chat, and the participant list

On leave:

  1. Browser closes or navigates away
  2. WebSocket disconnect fires on the Event DO
  3. Event DO marks the user as offline in the next alarm cycle
  4. LiveKit and Twilio detect the disconnect through their own mechanisms
  5. Other participants see the user disappear from the participant list

Pitfall: There's a brief window (up to 30 seconds) between a user disconnecting and other users seeing them go offline. This is because the Event DO alarm runs on a 30-second cycle. For more responsive disconnect detection, you could reduce the alarm interval, but this increases Durable Object costs.

Error Handling

Real-time connections are inherently unreliable. The system handles failures at multiple levels:

  • LiveKit — the SDK auto-reconnects on transient failures. If the room closes, the client receives a disconnect event.
  • Twilio — the SDK manages reconnection. If the conversation is deleted server-side, the client receives an error event.
  • PartyKit — WebSocket disconnects trigger onClose on the Event DO. The client is expected to reconnect. The Event DO sends the full snapshot on reconnection, so no state is lost.
  • Recording — the CloudcasterCoordinator runs health checks every 30 seconds. If Mux or LiveKit egress fails, it updates the session status and broadcasts the failure to connected clients.