Skip to main content
WebSockets Tutorial
CHAPTER 19 Beginner

Building a Complete Real-Time Project

Updated: May 14, 2026
45 min read

# CHAPTER 19

Building a Complete Real-Time Project

1. Introduction

You have studied the theory, learned the API, mastered the events, styled the UI, and understood backend broadcasting. Now, it is time for the capstone. In this chapter, we will integrate everything into a cohesive, professional-grade Real-Time Chat Room with Typing Indicators and Online User Tracking.

2. Learning Objectives

By the end of this chapter, you will be able to:
  • Combine Alpine.js, TailwindCSS, and WebSockets into a single app.
  • Implement complex JSON routing for multiple features.
  • Build a "User is typing..." indicator.
  • Track and display an active "Online Users" count.

3. Beginner-Friendly Explanation

Think of this project as assembling a car.
  • The HTML/Tailwind is the chassis and the paint job.
  • Alpine.js is the dashboard and the steering wheel, reacting to the driver.
  • The WebSocket Connection is the engine, powering the continuous movement.
  • The JSON Payload Structure is the electrical wiring, ensuring the right signals go to the right places (chat vs typing vs online status).

4. Features of our Capstone

  1. 1. Live Chat: Broadcast messages to everyone.
  1. 2. User Presence: Update the UI when users join or leave.
  1. 3. Typing Indicator: Show Alice is typing... when someone is typing.

5. Step-by-Step Architecture

Step 1: Define the JSON Protocol. Step 2: Build the Alpine Component State. Step 3: Implement the Input Event Listeners (for typing detection). Step 4: Write the onmessage router.

6. The JSON Protocol Definition

Our application will send and receive three types of messages:
  1. 1. { "type": "chat", "user": "Alice", "msg": "Hi" }
  1. 2. { "type": "typing", "user": "Alice", "isTyping": true }
  1. 3. { "type": "presence", "count": 5 }

7. The Complete Application Code

Save this as capstone.html. It simulates connection to a robust server. (For testing, we mock the server's presence updates, but the structure is perfectly production-ready).
html
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Pro Chat App</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-100 h-screen flex items-center justify-center font-sans">

    <div x-data="app()" class="w-full max-w-2xl bg-white rounded-lg shadow-xl overflow-hidden flex flex-col h-[700px]">
        
        <!-- Header -->
        <div class="bg-indigo-600 text-white p-4 flex justify-between items-center shadow z-10">
            <div>
                <h1 class="font-bold text-xl">Developer Lounge</h1>
                <p class="text-xs text-indigo-200">
                    <span class="h-2 w-2 inline-block rounded-full bg-green-400 mr-1"></span>
                    <span x-text="onlineCount"></span> Users Online
                </p>
            </div>
            
            <!-- User setup -->
            <div x-show="!joined" class="flex gap-2">
                <input x-model="username" type="text" placeholder="Enter username" class="px-2 py-1 text-black rounded text-sm">
                <button @click="joinChat" class="bg-indigo-500 px-3 py-1 rounded text-sm hover:bg-indigo-400">Join</button>
            </div>
            <div x-show="joined" class="text-sm font-bold bg-indigo-500 px-3 py-1 rounded" x-text="username"></div>
        </div>

        <!-- Main Chat Area -->
        <div class="flex-1 p-4 overflow-y-auto bg-gray-50 flex flex-col gap-4 relative" id="chat-window">
            
            <!-- Overlay if not joined -->
            <div x-show="!joined" class="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-10">
                <p class="text-xl font-bold text-gray-500">Please enter a username to join.</p>
            </div>

            <!-- Messages -->
            <template x-for="(msg, i) in messages" :key="i">
                <div class="flex" :class="msg.user === username ? &#039;justify-end' : 'justify-start'">
                    <!-- Avatar -->
                    <div x-show="msg.user !== username && msg.type !== &#039;system'" class="w-8 h-8 rounded-full bg-indigo-200 flex items-center justify-center text-indigo-700 font-bold mr-2 mt-1 flex-shrink-0" x-text="msg.user.charAt(0).toUpperCase()"></div>
                    
                    <!-- Chat Bubble -->
                    <div class="max-w-[70%]">
                        <span x-show="msg.user !== username && msg.type !== &#039;system'" class="text-xs text-gray-500 ml-1" x-text="msg.user"></span>
                        
                        <div class="p-3 rounded-lg shadow-sm"
                             :class="{
                                &#039;bg-indigo-600 text-white rounded-br-none': msg.user === username,
                                &#039;bg-white border rounded-bl-none': msg.user !== username && msg.type !== 'system',
                                &#039;bg-transparent text-center text-gray-400 text-xs italic shadow-none w-full': msg.type === 'system'
                             }">
                            <p class="text-sm" x-text="msg.msg"></p>
                        </div>
                    </div>
                </div>
            </template>
        </div>

        <!-- Typing Indicator Area -->
        <div class="h-6 bg-gray-50 px-4">
            <template x-if="typingUsers.length > 0">
                <p class="text-xs text-gray-500 italic">
                    <span x-text="typingUsers.join(&#039;, ')"></span> is typing...
                </p>
            </template>
        </div>

        <!-- Input Bar -->
        <div class="p-4 bg-white border-t flex gap-2">
            <input type="text" 
                   x-model="draft" 
                   @keydown="handleTyping"
                   @keydown.enter="sendMsg"
                   placeholder="Type a message..." 
                   class="flex-1 border border-gray-300 rounded-full px-4 py-2 focus:outline-none focus:border-indigo-500 disabled:bg-gray-100"
                   :disabled="!joined">
            
            <button @click="sendMsg" 
                    class="bg-indigo-600 text-white rounded-full p-2 w-10 h-10 flex items-center justify-center hover:bg-indigo-700 disabled:opacity-50"
                    :disabled="!joined || draft.trim() === &#039;'">
                ➤
            </button>
        </div>
    </div>

    <script>
        function app() {
            return {
                socket: null,
                joined: false,
                username: &#039;',
                draft: &#039;',
                messages: [{ type: &#039;system', msg: 'Welcome to the Developer Lounge.' }],
                onlineCount: 1,
                typingUsers: [],
                typingTimeout: null,

                joinChat() {
                    if(this.username.trim() === &#039;') return;
                    this.joined = true;
                    
                    // Connect to echo server
                    this.socket = new WebSocket("wss://echo.websocket.events");
                    
                    this.socket.onopen = () => {
                        // Mock presence update
                        this.onlineCount = Math.floor(Math.random() * 20) + 5;
                    };

                    this.socket.onmessage = (e) => this.routeData(e.data);
                },

                routeData(rawData) {
                    try {
                        const data = JSON.parse(rawData);
                        
                        switch(data.type) {
                            case &#039;chat':
                                // Ignore our own echoed messages (Optimistic UI handles it)
                                if (data.user !== this.username) {
                                    this.messages.push(data);
                                    this.scrollToBottom();
                                }
                                break;
                            case &#039;typing':
                                if (data.user === this.username) return;
                                
                                if (data.isTyping && !this.typingUsers.includes(data.user)) {
                                    this.typingUsers.push(data.user);
                                } else if (!data.isTyping) {
                                    this.typingUsers = this.typingUsers.filter(u => u !== data.user);
                                }
                                break;
                        }
                    } catch(e) {
                        // Non-JSON fallback
                    }
                },

                sendMsg() {
                    if (this.draft.trim() === &#039;') return;
                    
                    const payload = { type: &#039;chat', user: this.username, msg: this.draft };
                    
                    // Send to server
                    this.socket.send(JSON.stringify(payload));
                    
                    // Optimistic UI update
                    this.messages.push(payload);
                    this.draft = &#039;';
                    this.scrollToBottom();
                    
                    // Clear typing state
                    this.sendTypingState(false);
                },

                handleTyping(e) {
                    if (e.key === &#039;Enter') return;
                    
                    this.sendTypingState(true);
                    
                    clearTimeout(this.typingTimeout);
                    this.typingTimeout = setTimeout(() => {
                        this.sendTypingState(false);
                    }, 2000); // Stop typing after 2 seconds of inactivity
                },

                sendTypingState(isTyping) {
                    if (!this.socket || this.socket.readyState !== 1) return;
                    this.socket.send(JSON.stringify({
                        type: &#039;typing',
                        user: this.username,
                        isTyping: isTyping
                    }));
                },

                scrollToBottom() {
                    setTimeout(() => {
                        const el = document.getElementById(&#039;chat-window');
                        el.scrollTop = el.scrollHeight;
                    }, 50);
                }
            }
        }
    </script>
</body>
</html>

8. Feature Breakdown: Typing Indicators

How does "Alice is typing..." work?
  1. 1. The @keydown event fires on every keystroke.
  1. 2. It sends a {type: 'typing', isTyping: true} to the server.
  1. 3. To prevent leaving "Alice is typing..." permanently stuck on the screen if she walks away, we set a setTimeout for 2 seconds. Every keystroke clears and resets the timer (Debouncing).
  1. 4. If she stops typing for 2 seconds, the timer fires and sends {type: 'typing', isTyping: false}.

9. Best Practices

  • Debouncing: Rapid events like typing or mouse movements will flood your server if sent on every single event trigger. Throttle or debounce these events so you only send a WebSocket update occasionally.
  • Optimistic UI with Identifiers: In a real app, generate a unique id for each message payload. This prevents the echo server from duplicating the message in your UI.

10. Summary

In Chapter 19, we built a beautiful, comprehensive Real-Time project. We utilized a JSON router to gracefully handle multiple data types over a single connection, implemented advanced features like typing indicators with debouncing, and wrapped it all in an elegant Alpine/Tailwind interface. You are now officially a real-time developer!

11. Next Chapter Recommendation

Prepare to land your dream job. Proceed to Chapter 20: WebSocket Interview Questions and Practice Challenges to review everything we've learned and tackle the final assessment.

Finish this Chapter

Save your progress on your learning path and prepare for coding interview challenges.

Discussion

Join the discussion

Log in or create a free account to participate.

Sort: ·