Case Study 1: Building a Real-Time Chat Application


Overview

In this case study, we build a complete real-time chat application called ChatterBox that demonstrates the full-stack integration patterns from Chapter 19. The application includes user authentication, multiple chat rooms, real-time message delivery via WebSockets, persistent message history stored in a database, and a responsive React frontend. We will follow the iterative AI-assisted development process described in Section 19.10, showing the prompts used at each stage and the decisions made along the way.

Requirements

ChatterBox must support the following features:

  • User registration and login with JWT-based authentication
  • Chat rooms that users can create and join
  • Real-time messaging so all users in a room see messages instantly
  • Message history that persists across sessions and loads when a user joins a room
  • Online presence indicators showing who is currently in a room
  • Message timestamps displayed in the user's local timezone
  • Responsive design that works on desktop and mobile browsers

Architecture Decisions

Technology Stack

We chose the following stack based on the patterns established in Chapters 16-18:

Layer Technology Rationale
Frontend React + TypeScript + Vite Component-based UI, type safety, fast dev server
Backend FastAPI + Python Native WebSocket support, async capabilities, Pydantic validation
Database PostgreSQL + SQLAlchemy Relational data model, reliable for message storage
Real-time WebSockets (native FastAPI) Bidirectional, low-latency communication
Auth JWT with bcrypt Stateless authentication, works with both HTTP and WebSocket

Data Model

The application requires three core tables:

users — stores registered accounts: - id (primary key) - username (unique, 3-30 characters) - email (unique) - hashed_password - avatar_url (optional) - created_at

rooms — stores chat rooms: - id (primary key) - name (unique, 1-100 characters) - description (optional) - created_by (foreign key to users) - created_at

messages — stores chat messages: - id (primary key) - content (1-5000 characters) - user_id (foreign key to users) - room_id (foreign key to rooms) - created_at (indexed for efficient history queries)

We chose to store messages in PostgreSQL rather than an in-memory store because message history is a core feature. For applications with extremely high message volumes (millions per day), a dedicated message store like Apache Kafka or a time-series database would be more appropriate, but PostgreSQL handles moderate volumes well.

WebSocket Architecture

The WebSocket architecture uses a room-based connection manager. When a user opens a chat room, the frontend establishes a WebSocket connection to /ws/{room_id}?token={jwt}. The server validates the token, adds the connection to the room's connection set, and begins relaying messages.

User A (Room 1) ─── WebSocket ───┐
                                  │
User B (Room 1) ─── WebSocket ───┤── ConnectionManager ── Room 1
                                  │
User C (Room 1) ─── WebSocket ───┘

User D (Room 2) ─── WebSocket ───┐
                                  ├── ConnectionManager ── Room 2
User E (Room 2) ─── WebSocket ───┘

Each room maintains its own set of connections. When a message arrives from any user in the room, the ConnectionManager broadcasts it to all other connections in that room.

Development Process

Phase 1: Database Models and Authentication

We started with the foundation: database models and authentication. The AI prompt:

"Create SQLAlchemy models for a chat application with User, Room, and Message
tables. Include proper relationships, indexes on frequently queried columns,
and timestamps. Then create the authentication endpoints: register, login,
and get-current-user. Use bcrypt for password hashing and JWT for tokens."

The AI generated the models with appropriate relationships (User.messages, Room.messages, Message.author) and the auth endpoints matching the pattern from Section 19.5. One correction was needed: the AI initially forgot to add an index on messages.created_at, which is essential for efficiently loading message history in chronological order.

Phase 2: Room Management API

Next, we built the REST API for room management:

"Create FastAPI endpoints for chat room management:
- POST /api/rooms — create a new room (requires auth)
- GET /api/rooms — list all rooms with member counts
- GET /api/rooms/{id} — get room details including recent messages
- POST /api/rooms/{id}/join — join a room
- POST /api/rooms/{id}/leave — leave a room
Include Pydantic schemas for requests and responses."

The room listing endpoint needed special attention. We wanted to show the number of online users in each room, not just the total member count. This required the ConnectionManager to expose a method for counting active connections per room:

@router.get("/api/rooms")
async def list_rooms(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    rooms = db.query(Room).all()
    return [
        RoomListResponse(
            id=room.id,
            name=room.name,
            description=room.description,
            member_count=len(room.members),
            online_count=manager.get_connection_count(str(room.id)),
            last_message=get_last_message(db, room.id),
        )
        for room in rooms
    ]

Phase 3: WebSocket Message Handling

The real-time messaging system was the most complex part. Our prompt:

"Implement WebSocket message handling for the chat app:
1. Authenticate the WebSocket connection using a JWT token in the query string
2. On connect: load the last 50 messages from the database and send them
3. Broadcast new messages to all users in the room
4. On disconnect: notify the room that the user has left
5. Handle different message types: text messages, typing indicators, and
   system messages (user joined/left)
Store all text messages in the database for history."

The WebSocket endpoint handles multiple message types through a type field:

@app.websocket("/ws/{room_id}")
async def websocket_endpoint(
    websocket: WebSocket,
    room_id: str,
    token: str = Query(...),
    db: Session = Depends(get_db),
):
    # Authenticate
    user = authenticate_websocket(token)
    if not user:
        await websocket.close(code=4001)
        return

    # Connect and send history
    await manager.connect(websocket, room_id, user.id)
    history = get_message_history(db, int(room_id), limit=50)
    await websocket.send_json({"type": "history", "messages": history})

    # Broadcast join notification
    await manager.broadcast(room_id, {
        "type": "system",
        "content": f"{user.username} joined the room",
        "timestamp": datetime.now(timezone.utc).isoformat(),
    })

    try:
        while True:
            data = await websocket.receive_json()

            if data["type"] == "message":
                # Store in database
                message = Message(
                    content=data["content"],
                    user_id=user.id,
                    room_id=int(room_id),
                )
                db.add(message)
                db.commit()

                # Broadcast to room
                await manager.broadcast(room_id, {
                    "type": "message",
                    "id": message.id,
                    "content": message.content,
                    "sender": user.username,
                    "avatar_url": user.avatar_url,
                    "timestamp": message.created_at.isoformat(),
                })

            elif data["type"] == "typing":
                # Broadcast typing indicator (don't store)
                await manager.broadcast_except(room_id, websocket, {
                    "type": "typing",
                    "sender": user.username,
                })

    except WebSocketDisconnect:
        manager.disconnect(websocket, room_id)
        await manager.broadcast(room_id, {
            "type": "system",
            "content": f"{user.username} left the room",
            "timestamp": datetime.now(timezone.utc).isoformat(),
        })

A key design decision was the broadcast_except method for typing indicators. Without it, the user who is typing would see their own typing indicator, which is both unnecessary and confusing.

Phase 4: React Frontend

The frontend development proceeded in stages:

Login/Registration Pages: Standard forms with validation, using the AuthContext pattern from Section 19.5. The AI generated these correctly on the first try because the pattern is well-established.

Room List Page: Displays all available rooms with online user counts, last message preview, and a button to create new rooms. We used the useApi hook from Section 19.3 for data fetching:

function RoomList() {
    const { data: rooms, loading, error } = useApi<Room[]>('/api/rooms');
    const [showCreateModal, setShowCreateModal] = useState(false);

    if (loading) return <LoadingSpinner />;
    if (error) return <ErrorDisplay message={error} />;

    return (
        <div className="room-list">
            <header>
                <h1>Chat Rooms</h1>
                <button onClick={() => setShowCreateModal(true)}>
                    Create Room
                </button>
            </header>
            <div className="rooms">
                {rooms?.map(room => (
                    <RoomCard key={room.id} room={room} />
                ))}
            </div>
            {showCreateModal && (
                <CreateRoomModal onClose={() => setShowCreateModal(false)} />
            )}
        </div>
    );
}

Chat Room Page: The most complex component. It manages the WebSocket connection, message list, message input, typing indicators, and online user list:

function ChatRoom({ roomId }: { roomId: string }) {
    const [messages, setMessages] = useState<Message[]>([]);
    const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
    const [typingUsers, setTypingUsers] = useState<string[]>([]);
    const { user } = useAuth();
    const messagesEndRef = useRef<HTMLDivElement>(null);

    const { isConnected, sendMessage } = useWebSocket({
        url: `${WS_URL}/ws/${roomId}?token=${getToken()}`,
        onMessage: (data) => {
            switch (data.type) {
                case 'history':
                    setMessages(data.messages);
                    break;
                case 'message':
                    setMessages(prev => [...prev, data]);
                    break;
                case 'system':
                    setMessages(prev => [...prev, {
                        ...data, isSystem: true
                    }]);
                    break;
                case 'typing':
                    handleTypingIndicator(data.sender);
                    break;
                case 'presence':
                    setOnlineUsers(data.users);
                    break;
            }
        },
    });

    // Auto-scroll to bottom on new messages
    useEffect(() => {
        messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
    }, [messages]);

    return (
        <div className="chat-room">
            <aside className="online-users">
                <h3>Online ({onlineUsers.length})</h3>
                {onlineUsers.map(u => <UserBadge key={u} username={u} />)}
            </aside>
            <main className="messages-area">
                <MessageList messages={messages} currentUser={user} />
                <div ref={messagesEndRef} />
                {typingUsers.length > 0 && (
                    <TypingIndicator users={typingUsers} />
                )}
                <MessageInput
                    onSend={(content) => sendMessage({
                        type: 'message', content
                    })}
                    onTyping={() => sendMessage({ type: 'typing' })}
                    disabled={!isConnected}
                />
            </main>
        </div>
    );
}

Phase 5: Polish and Edge Cases

The final phase addressed the issues that surface during real usage:

Message ordering. Messages must appear in chronological order even when multiple users send messages simultaneously. We sort by the server-assigned timestamp, not the client's local time.

Reconnection handling. When the WebSocket connection drops (network issue, server restart), the client must reconnect automatically and reload recent messages to fill any gaps. The useWebSocket hook includes reconnection logic with exponential backoff.

Input sanitization. Message content is sanitized on both the backend (Pydantic validation, HTML escaping) and the frontend (React's built-in XSS protection through JSX escaping) to prevent cross-site scripting attacks.

Scroll behavior. New messages auto-scroll to the bottom only if the user is already at the bottom. If they have scrolled up to read history, new messages should not interrupt their reading. This required tracking the scroll position:

const shouldAutoScroll = () => {
    const container = messagesContainerRef.current;
    if (!container) return true;
    const threshold = 100; // pixels from bottom
    return container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
};

Typing indicator debouncing. Without debouncing, every keystroke sends a typing notification. We debounce to send at most one typing event per second, and clear the indicator after 3 seconds of inactivity.

Challenges Encountered

Challenge 1: Database Session Management with WebSockets

Standard FastAPI dependency injection creates a database session per request and closes it after the response. WebSocket connections are long-lived, so the session either stays open for the entire connection (risking stale data and connection pool exhaustion) or must be managed manually. We solved this by creating a new session for each database operation within the WebSocket handler:

async def save_message(content: str, user_id: int, room_id: int) -> Message:
    with SessionLocal() as db:
        message = Message(content=content, user_id=user_id, room_id=room_id)
        db.add(message)
        db.commit()
        db.refresh(message)
        return message

Challenge 2: Scaling Beyond a Single Server

The in-memory ConnectionManager works for a single server instance but fails when you run multiple backend instances behind a load balancer. User A might be connected to Server 1 while User B is connected to Server 2. A message from User A would only be broadcast to connections on Server 1.

The solution is a pub/sub layer. Each server subscribes to a Redis channel for each room. When a message arrives, the server publishes it to Redis, and all servers receive and broadcast it to their local connections. We did not implement this in the initial version but documented it as the scaling path for when the application outgrows a single server.

Challenge 3: Message History Pagination

Loading 50 messages on join works for recent rooms, but rooms with thousands of messages need pagination. We implemented "load more" functionality where scrolling to the top triggers a request for older messages:

@router.get("/api/rooms/{room_id}/messages")
async def get_messages(
    room_id: int,
    before_id: Optional[int] = None,
    limit: int = Query(default=50, le=100),
    db: Session = Depends(get_db),
):
    query = db.query(Message).filter(Message.room_id == room_id)
    if before_id:
        query = query.filter(Message.id < before_id)
    messages = query.order_by(Message.created_at.desc()).limit(limit).all()
    return list(reversed(messages))  # Return in chronological order

Results and Metrics

The completed ChatterBox application demonstrates the following full-stack integration patterns:

  • End-to-end authentication: Registration, login, JWT storage, protected routes, WebSocket auth
  • REST + WebSocket coexistence: Room management via REST, messaging via WebSocket
  • State synchronization: Message history from the database, real-time updates from WebSockets
  • Error handling across layers: Network errors, validation errors, authentication errors all handled gracefully

Performance benchmarks on a single server (4 CPU cores, 8 GB RAM): - Handles 200+ concurrent WebSocket connections per room - Message delivery latency under 50ms (same server) - Message history loads in under 100ms for the most recent 50 messages - Database stores 1 million+ messages without noticeable query degradation (with proper indexing)

Lessons Learned

  1. Define message types early. The WebSocket protocol benefits from a clear taxonomy of message types (text, typing, system, presence) defined before implementation begins. Adding new types later requires changes across both frontend and backend.

  2. Test with multiple browser tabs. The easiest way to test real-time features is to open your application in multiple browser tabs, each logged in as a different user. This catches synchronization issues that are invisible when testing with a single client.

  3. AI excels at the boilerplate. The ConnectionManager, WebSocket hook, auth context, and API client are all patterns the AI generates reliably. The creative decisions — message ordering, scroll behavior, typing indicator UX — required human judgment.

  4. Plan for the database session lifecycle. Long-lived WebSocket connections interact with database sessions differently than short-lived HTTP requests. Decide on your session strategy (per-operation, per-connection, or pooled) before building the WebSocket handlers.

  5. Start with HTTP, then add WebSocket. We initially considered using WebSockets for everything, but REST endpoints are simpler for CRUD operations on rooms and user profiles. WebSockets should only be used where real-time push is genuinely needed.