Pelajari apa itu WebSocket dan bagaimana mengimplementasikannya pada website pencarian properti/rumah. Artikel ini membahas konsep WebSocket vs HTTP polling, real-world use cases untuk platform real estate, serta contoh implementasi dengan berbagai framework populer: Laravel Reverb, Node.js Socket.io, Golang Gorilla WebSocket, React, Vue.js, dan Next.js. Lengkap dengan fitur live chat dengan agen, real-time property updates, live auction/bidding, dan notification system.
Bagian 1: Kenapa Real-time Penting untuk Platform Properti
Sebagai founder BuildWithAngga dengan pengalaman membangun berbagai platform untuk 900.000+ students, saya sering melihat satu pattern yang sama: developer membangun fitur lengkap, tapi lupa bahwa user expectation sudah berubah.
User sekarang terbiasa dengan WhatsApp yang instant. Instagram yang real-time. Tokopedia yang kasih notifikasi langsung saat ada update. Mereka expect experience yang sama di platform manapun — termasuk platform cari rumah.
Masalah Platform Properti Tanpa Real-time
Bayangkan skenario ini:
Skenario 1: Chat dengan Agen
Andi sedang cari rumah di Jakarta Selatan. Dia menemukan rumah yang menarik di Kemang, harga pas dengan budget. Ada pertanyaan tentang legalitas sertifikat, jadi dia klik "Chat dengan Agen".
TIMELINE TANPA REAL-TIME:
14:00 - Andi kirim pesan: "Pak, sertifikat rumah ini SHM atau HGB?"
14:01 - Andi refresh halaman... tidak ada balasan
14:05 - Refresh lagi... masih kosong
14:10 - Refresh lagi... masih kosong (Andi mulai frustasi)
14:15 - Andi buka tab lain, lupa dengan chat
14:30 - Agen sudah balas 15 menit lalu: "SHM Pak, bisa survey besok?"
14:45 - Andi baru lihat balasan, tapi agen sudah offline
15:00 - Andi hubungi agen lain, deal di properti berbeda
HASIL: Lost opportunity. Agen kehilangan potential buyer.
Skenario 2: Status Properti Outdated
Budi tertarik dengan 3 rumah di Bintaro. Dia save ketiga rumah ke wishlist dan mulai compare.
TIMELINE TANPA REAL-TIME:
09:00 - Budi buka detail Rumah A, masih "Available"
09:30 - Orang lain bayar DP untuk Rumah A (status harusnya berubah)
10:00 - Budi selesai compare, pilih Rumah A
10:05 - Budi isi form inquiry untuk Rumah A
10:10 - Dapat email: "Maaf, rumah sudah sold 1 jam lalu"
10:15 - Budi frustasi, wasted 1 jam untuk rumah yang sudah tidak available
HASIL: Bad user experience. Budi mungkin tidak kembali ke platform.
Skenario 3: Lelang Online
Citra ikut lelang rumah di Menteng. Starting bid Rp 2 Miliar.
TIMELINE TANPA REAL-TIME:
10:00 - Citra bid Rp 2.1M
10:02 - Peserta lain bid Rp 2.2M (Citra tidak tahu!)
10:05 - Citra refresh manual, lihat bid Rp 2.2M
10:06 - Citra bid Rp 2.3M
10:07 - Peserta lain bid Rp 2.35M
10:08 - Citra tidak refresh, tidak tahu
10:10 - Lelang berakhir, Citra kalah di Rp 2.35M
10:11 - Citra baru tahu setelah refresh
HASIL: Unfair auction. Citra kehilangan rumah impian
karena tidak dapat update real-time.
Solusi: Real-time dengan WebSocket
Di sinilah WebSocket masuk. WebSocket memungkinkan komunikasi dua arah yang persistent antara browser dan server. Artinya:
- Server bisa push data ke client kapan saja
- Tidak perlu refresh atau polling
- Update terjadi instant, dalam milliseconds
- Experience seperti WhatsApp, bukan seperti email
DENGAN WEBSOCKET:
CHAT:
├── Andi kirim pesan
├── Agen dapat notifikasi INSTANT
├── Agen balas, Andi dapat INSTANT
├── "Agen sedang mengetik..." muncul real-time
└── Deal terjadi dalam 10 menit
PROPERTY STATUS:
├── Rumah A sold
├── SEMUA user yang sedang view dapat notifikasi
├── Status berubah live di screen mereka
├── Tidak ada yang submit inquiry untuk rumah sold
└── Better experience, less frustration
AUCTION:
├── Setiap bid broadcast ke SEMUA peserta
├── Countdown timer synchronized
├── "Anda telah di-outbid!" muncul instant
├── Fair competition untuk semua
└── Transparent dan trustworthy
Fitur Real-time yang Akan Kita Bangun
Dalam artikel ini, kita akan explore 5 fitur real-time essential untuk platform properti:
┌─────────────────────────────────────────────────────────┐
│ FITUR REAL-TIME PLATFORM PROPERTI │
├─────────────────────────────────────────────────────────┤
│ │
│ 💬 LIVE CHAT │
│ ├── Chat langsung dengan agen properti │
│ ├── Typing indicator ("Agen sedang mengetik...") │
│ ├── Read receipts (✓✓ sudah dibaca) │
│ └── Online/offline status │
│ │
│ 🏠 PROPERTY STATUS UPDATES │
│ ├── Status berubah: Available → Under Offer → Sold │
│ ├── Price drop notifications │
│ ├── New listings yang match saved search │
│ └── Virtual tour available alerts │
│ │
│ 🔨 LIVE AUCTION / BIDDING │
│ ├── Real-time bid updates │
│ ├── Countdown timer yang synchronized │
│ ├── Bid history live stream │
│ └── "You've been outbid!" instant alerts │
│ │
│ 👀 LIVE PRESENCE (Social Proof) │
│ ├── "12 orang sedang melihat properti ini" │
│ ├── "5 orang menyimpan ke wishlist hari ini" │
│ └── Agen online/offline status │
│ │
│ 🔔 INSTANT NOTIFICATIONS │
│ ├── New listing matches saved search │
│ ├── Agen replied to your inquiry │
│ ├── Viewing appointment reminders │
│ └── Document ready for download │
│ │
└─────────────────────────────────────────────────────────┘
Apa yang Akan Kamu Pelajari
Artikel ini akan memberikan pemahaman komprehensif tentang WebSocket:
ROADMAP ARTIKEL:
📖 KONSEP & TEORI
├── Apa itu WebSocket dan cara kerjanya
├── HTTP vs WebSocket vs SSE vs Polling
├── Kapan gunakan dan kapan hindari WebSocket
└── WebSocket handshake process
💻 BACKEND IMPLEMENTATION
├── Laravel Reverb (PHP ecosystem)
├── Node.js dengan Socket.io (JavaScript ecosystem)
└── Golang dengan Gorilla WebSocket (High-performance)
🎨 FRONTEND IMPLEMENTATION
├── React dengan custom hooks
├── Vue.js dengan Composition API
└── Next.js dengan context provider
🚀 PRODUCTION READY
├── Security best practices
├── Scaling strategies
├── Error handling & reconnection
└── Monitoring & debugging
Setiap implementasi akan disertai code examples yang production-ready — bukan hello world, tapi real features untuk platform properti.
Mari kita mulai dengan memahami: apa sebenarnya WebSocket itu?
Bagian 2: Apa itu WebSocket? Konsep dan Cara Kerja
Definisi WebSocket
WebSocket adalah protokol komunikasi yang menyediakan full-duplex (dua arah) communication channel melalui single TCP connection. Berbeda dengan HTTP yang bersifat request-response, WebSocket memungkinkan server dan client untuk saling kirim data kapan saja tanpa harus menunggu request.
WEBSOCKET AT A GLANCE:
├── Protocol: ws:// (atau wss:// untuk secure)
├── Standard: RFC 6455 (2011)
├── Port: 80 (ws) atau 443 (wss)
├── Connection: Persistent (tetap terbuka)
├── Direction: Bidirectional (dua arah)
├── Overhead: Minimal setelah handshake
└── Support: Semua modern browsers
HTTP vs WebSocket: Analogi Sederhana
Untuk memahami perbedaan fundamental, mari gunakan analogi yang relatable.
HTTP seperti KIRIM SURAT:
┌─────────────────────────────────────────────────────────┐
│ HTTP: KIRIM SURAT │
├─────────────────────────────────────────────────────────┤
│ │
│ User (Andi) Server (Agen) │
│ │ │ │
│ │ │ │
│ │──── ✉️ Kirim surat ────────────────►│ │
│ │ "Rumah ini masih available?" │ │
│ │ │ Baca... │
│ │ │ Tulis... │
│ │◄──── ✉️ Balas surat ───────────────│ │
│ │ "Masih available, Pak" │ │
│ │ │ │
│ │ │ │
│ │──── ✉️ Kirim surat lagi ───────────►│ │
│ │ "Bisa survey kapan?" │ │
│ │ │ Baca... │
│ │ │ Tulis... │
│ │◄──── ✉️ Balas surat ───────────────│ │
│ │ "Besok jam 10 bisa" │ │
│ │
│ KARAKTERISTIK: │
│ • Setiap komunikasi butuh "surat" baru (new request) │
│ • Server TIDAK BISA kirim duluan tanpa diminta │
│ • Ada delay antara kirim dan terima │
│ • Overhead: amplop, perangko setiap surat (headers) │
│ │
└─────────────────────────────────────────────────────────┘
WebSocket seperti TELEPON:
┌─────────────────────────────────────────────────────────┐
│ WEBSOCKET: TELEPON │
├─────────────────────────────────────────────────────────┤
│ │
│ User (Andi) Server (Agen) │
│ │ │ │
│ │══════ 📞 Dial & Connect ═══════════►│ │
│ │◄══════════════════════════════════ ═│ │
│ │ │ │
│ │ ═══ CONNECTED ═══ │ │
│ │ (Telepon tetap tersambung) │ │
│ │ │ │
│ │──── "Halo, rumah masih ada?" ──────►│ │
│ │◄──── "Masih ada, Pak" ─────────────│ │
│ │──── "Bisa survey besok?" ──────────►│ │
│ │◄──── "Bisa, jam berapa?" ──────────│ │
│ │◄──── "Oh iya, ada diskon 5%!" ─────│ ← Server │
│ │ (Server kirim tanpa diminta!) │ initiate! │
│ │──── "Wah, bagus! Deal!" ───────────►│ │
│ │ │ │
│ │
│ KARAKTERISTIK: │
│ • Satu kali dial (handshake) untuk seterusnya │
│ • KEDUA PIHAK bisa bicara kapan saja │
│ • Instant, real-time │
│ • Minimal overhead setelah tersambung │
│ │
└─────────────────────────────────────────────────────────┘
WebSocket Handshake: Bagaimana Connection Terbentuk
WebSocket dimulai dengan HTTP request biasa, kemudian "upgrade" ke WebSocket protocol.
┌─────────────────────────────────────────────────────────┐
│ WEBSOCKET HANDSHAKE PROCESS │
├─────────────────────────────────────────────────────────┤
│ │
│ STEP 1: Client kirim HTTP Upgrade Request │
│ ───────────────────────────────────────── │
│ GET /chat HTTP/1.1 │
│ Host: rumahku.com │
│ Upgrade: websocket ← "Mau upgrade ke WS" │
│ Connection: Upgrade │
│ Sec-WebSocket-Key: dGhlIHNhbXBsZQ== ← Security key │
│ Sec-WebSocket-Version: 13 │
│ Origin: <https://rumahku.com> │
│ │
│ │ │
│ ▼ │
│ │
│ STEP 2: Server respond dengan 101 Switching Protocols │
│ ───────────────────────────────────────── │
│ HTTP/1.1 101 Switching Protocols │
│ Upgrade: websocket ← "OK, upgrade accepted" │
│ Connection: Upgrade │
│ Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK= │
│ │
│ │ │
│ ▼ │
│ │
│ STEP 3: Connection Established! ✅ │
│ ───────────────────────────────────────── │
│ Sekarang client dan server bisa kirim WebSocket frames │
│ tanpa HTTP overhead. Connection persistent sampai │
│ salah satu pihak close atau disconnect. │
│ │
└─────────────────────────────────────────────────────────┘
Comparison: Semua Opsi Real-time
WebSocket bukan satu-satunya cara untuk real-time. Mari compare semua opsi:
┌───────────────┬────────────────┬────────────────┬────────────────┬────────────────┐
│ Aspect │ Polling │ Long Polling │ SSE │ WebSocket │
├───────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ Arah │ Client → Srv │ Client → Srv │ Server → Cli │ Bidirectional │
│ │ │ │ (one-way) │ (dua arah) │
├───────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ Connection │ New setiap │ Held sampai │ Persistent │ Persistent │
│ │ poll │ ada data │ │ │
├───────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ Real-time? │ ❌ Delayed │ ⚠️ Near │ ✅ Yes │ ✅ Yes │
│ │ (sesuai │ real-time │ │ │
│ │ interval) │ │ │ │
├───────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ Overhead │ 🔴 Tinggi │ 🟡 Medium │ 🟢 Rendah │ 🟢 Paling │
│ │ (request │ │ │ rendah │
│ │ terus2an) │ │ │ │
├───────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ Server Push │ ❌ Tidak bisa │ ❌ Tidak bisa │ ✅ Bisa │ ✅ Bisa │
├───────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ Client Push │ ✅ Bisa │ ✅ Bisa │ ❌ Tidak bisa │ ✅ Bisa │
│ │ (via request) │ │ (butuh HTTP) │ │
├───────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ Complexity │ Simple │ Medium │ Simple │ Medium │
├───────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ Browser │ All │ All │ Semua modern │ Semua modern │
│ Support │ │ │ (IE ❌) │ │
├───────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ Best For │ Simple status │ Notifications │ News feeds, │ Chat, games, │
│ │ check │ basic │ stock tickers │ auctions, │
│ │ │ │ │ collaboration │
└───────────────┴────────────────┴────────────────┴────────────────┴────────────────┘
Penjelasan Detail:
1. Polling (Short Polling)
// Client terus-terusan tanya: "Ada data baru?"
setInterval(async () => {
const response = await fetch('/api/messages');
const messages = await response.json();
updateUI(messages);
}, 3000); // Setiap 3 detik
// MASALAH:
// - Kalau interval 3 detik, delay bisa 0-3 detik
// - Request terus jalan walau tidak ada data baru
// - Boros bandwidth dan server resources
2. Long Polling
// Client tanya, server hold sampai ada data
async function longPoll() {
try {
const response = await fetch('/api/messages/wait'); // Server hold
const message = await response.json();
handleNewMessage(message);
longPoll(); // Reconnect
} catch (error) {
setTimeout(longPoll, 1000); // Retry
}
}
// LEBIH BAIK:
// - Hampir real-time
// - Tapi tetap butuh reconnect setiap ada data
// - Server harus hold banyak connections
3. Server-Sent Events (SSE)
// Server push ke client (satu arah)
const eventSource = new EventSource('/api/stream');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
handleUpdate(data);
};
// BAGUS UNTUK:
// - Notifications
// - Live feeds
// - Stock prices
// TIDAK BISA:
// - Client kirim data (butuh HTTP terpisah)
4. WebSocket
// Dua arah, persistent
const socket = new WebSocket('wss://rumahku.com/ws');
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
handleServerMessage(data);
};
socket.send(JSON.stringify({ type: 'chat', message: 'Hello!' }));
// BISA SEMUA:
// - Server push ✅
// - Client push ✅
// - Real-time ✅
// - Efficient ✅
Kapan Gunakan WebSocket?
✅ GUNAKAN WEBSOCKET UNTUK:
├── Chat applications
│ └── Butuh dua arah, real-time
│
├── Live auctions / bidding
│ └── Update harus instant dan fair
│
├── Real-time collaboration
│ └── Multiple users edit bersamaan
│
├── Live notifications yang butuh response
│ └── User perlu bisa respond langsung
│
├── Multiplayer games
│ └── State sync harus real-time
│
├── Live dashboards interaktif
│ └── Data update + user interaction
│
└── Presence features
└── Online status, typing indicators
❌ JANGAN GUNAKAN WEBSOCKET UNTUK:
├── Simple form submissions
│ └── HTTP POST cukup
│
├── File uploads
│ └── HTTP dengan progress event lebih cocok
│
├── One-time data fetches
│ └── HTTP GET lebih simple
│
├── Cacheable content
│ └── HTTP dengan caching headers
│
├── SEO-critical content
│ └── Server-side rendering dengan HTTP
│
└── Notifications satu arah saja
└── SSE lebih simple dan cukup
WebSocket untuk Platform Properti: Perfect Match
Untuk platform cari rumah yang kita bahas:
┌────────────────────────┬────────────────────┬─────────────────────┐
│ Fitur │ Butuh 2 Arah? │ Verdict │
├────────────────────────┼────────────────────┼─────────────────────┤
│ Live Chat │ ✅ Ya (kirim + │ ✅ WebSocket │
│ │ terima) │ │
├────────────────────────┼────────────────────┼─────────────────────┤
│ Property Status │ ⚠️ Mostly receive, │ ✅ WebSocket atau │
│ Updates │ tapi bisa filter │ SSE │
├────────────────────────┼────────────────────┼─────────────────────┤
│ Live Auction │ ✅ Ya (bid + │ ✅ WebSocket │
│ │ receive updates)│ (wajib) │
├────────────────────────┼────────────────────┼─────────────────────┤
│ Presence (viewers) │ ✅ Ya (join + │ ✅ WebSocket │
│ │ receive count) │ │
├────────────────────────┼────────────────────┼─────────────────────┤
│ Notifications │ ⚠️ Mostly receive │ ✅ WebSocket atau │
│ │ │ SSE │
└────────────────────────┴────────────────────┴─────────────────────┘
VERDICT: WebSocket adalah pilihan IDEAL untuk platform properti
karena hampir semua fitur butuh bidirectional communication.
Dengan fondasi konsep yang sudah solid, di bagian selanjutnya kita akan detail setiap fitur real-time yang relevan untuk platform properti.
Bagian 3: 5 Fitur Real-time untuk Platform Cari Rumah
Sekarang kita masuk ke bagian praktis — fitur apa saja yang bisa diimplementasikan dengan WebSocket untuk platform properti? Berikut 5 fitur yang akan memberikan competitive advantage signifikan.
FITUR #1: Live Chat dengan Agen Properti
Ini adalah fitur paling essential. Komunikasi cepat antara potential buyer dan agen adalah key factor dalam closing deals.
User Journey:
USER FLOW:
1. User browsing properti di Kemang
2. Menemukan rumah yang menarik
3. Ada pertanyaan tentang negotiable price
4. Klik tombol "💬 Chat dengan Agen"
5. Chat window muncul
6. Mulai percakapan instant
┌─────────────────────────────────────────────────────────┐
│ 💬 Chat dengan Agen ─ □ x │
├─────────────────────────────────────────────────────────┤
│ │
│ 🟢 Budi Santoso (Online) │
│ Property Agent • Biasanya reply dalam 2 menit │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ Hari ini, 14:23 │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Halo Pak, untuk rumah di Kemang │ ← User │
│ │ ini harganya masih bisa nego? │ │
│ └─────────────────────────────────────┘ │
│ ✓✓ Dibaca │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Halo Pak! Iya, masih bisa nego │ ← Agent │
│ │ sekitar 5-7%. Mau schedule │ │
│ │ viewing dulu? │ │
│ └─────────────────────────────────────┘ │
│ │
│ Agen sedang mengetik... │
│ │
├─────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────┐ 📎 │
│ │ Ketik pesan... │ 📷 │
│ └───────────────────────────────────────────────┘ ➤ │
└─────────────────────────────────────────────────────────┘
WebSocket Events yang Dibutuhkan:
CHAT EVENTS:
CLIENT → SERVER:
├── send_message
│ └── { conversationId, content, attachments? }
├── typing_start
│ └── { conversationId }
├── typing_stop
│ └── { conversationId }
├── mark_as_read
│ └── { conversationId, lastMessageId }
└── join_conversation
└── { conversationId }
SERVER → CLIENT:
├── new_message
│ └── { from, content, timestamp, id }
├── user_typing
│ └── { userId, userName }
├── user_stopped_typing
│ └── { userId }
├── message_read
│ └── { messageId, readBy, readAt }
├── user_online
│ └── { userId, userName }
└── user_offline
└── { userId, lastSeen }
Business Impact:
TANPA LIVE CHAT: DENGAN LIVE CHAT:
──────────────── ─────────────────
Response time: 2-24 jam Response time: 2-5 menit
Conversion rate: 3-5% Conversion rate: 15-25%
User satisfaction: ⭐⭐⭐ User satisfaction: ⭐⭐⭐⭐⭐
Deal closure: 5-7 hari Deal closure: 1-3 hari
FITUR #2: Real-time Property Status Updates
Tidak ada yang lebih frustrating dari menghabiskan waktu untuk properti yang ternyata sudah sold.
Use Case:
SCENARIO:
User A sedang melihat detail Rumah di Pondok Indah
User B (di tempat lain) baru saja bayar DP untuk rumah yang sama
TANPA REAL-TIME:
├── User A tidak tahu
├── User A submit inquiry
├── User A tunggu reply
├── 2 jam kemudian dapat email: "Maaf sudah sold"
└── User A frustasi, bad experience
DENGAN REAL-TIME:
├── User B bayar DP
├── Server broadcast: "Status changed to UNDER OFFER"
├── User A INSTANT dapat update
├── UI berubah: tombol "Inquiry" → "Under Offer"
├── User A langsung lihat properti lain
└── No wasted time, good experience
Events yang Dibroadcast:
PROPERTY UPDATE EVENTS:
┌─────────────────────────┬────────────────────────────────┐
│ Event │ Trigger │
├─────────────────────────┼────────────────────────────────┤
│ status_changed │ Available → Under Offer │
│ │ Under Offer → Sold │
│ │ Available → Rented │
├─────────────────────────┼────────────────────────────────┤
│ price_updated │ Price drop atau increase │
│ │ → Terutama untuk price DROP │
│ │ (trigger urgency) │
├─────────────────────────┼────────────────────────────────┤
│ new_photos_added │ Owner upload foto baru │
│ │ Virtual tour tersedia │
├─────────────────────────┼────────────────────────────────┤
│ open_house_scheduled │ Ada jadwal open house │
│ │ Slots almost full │
├─────────────────────────┼────────────────────────────────┤
│ property_featured │ Property jadi featured │
│ │ (boost visibility) │
└─────────────────────────┴────────────────────────────────┘
UI Update Example:
BEFORE UPDATE: AFTER REAL-TIME UPDATE:
────────────── ──────────────────────
┌──────────────────────┐ ┌──────────────────────┐
│ 🏠 Rumah Pondok │ │ 🏠 Rumah Pondok │
│ Indah │ │ Indah │
│ │ │ │
│ Rp 5.5 Miliar │ │ ⚡ Rp 5.2 Miliar │
│ │ ───► │ 💰 PRICE DROP! │
│ ┌────────────────┐ │ │ │
│ │ 📞 Inquiry │ │ │ ┌────────────────┐ │
│ └────────────────┘ │ │ │ 📞 Inquiry │ │
│ │ │ └────────────────┘ │
│ 🟢 Available │ │ 🟢 Available │
└──────────────────────┘ └──────────────────────┘
↑ Toast notification juga muncul:
"🔔 Harga Rumah Pondok Indah turun!"
FITUR #3: Live Property Auction / Bidding
Untuk properti yang dijual via lelang, real-time adalah WAJIB. Tidak fair jika ada delay.
Auction Flow:
┌─────────────────────────────────────────────────────────┐
│ 🔨 LIVE AUCTION: Rumah Menteng │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ │ │
│ │ CURRENT BID: Rp 2,350,000,000 │ │
│ │ by: Bidder #7 (You've been outbid!) │ │
│ │ │ │
│ │ ⏱️ Time Remaining: 00:05:23 │ │
│ │ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 📊 BID HISTORY (Live) │
│ ───────────────────── │
│ 14:23:45 Bidder #7 Rp 2,350,000,000 ← Latest │
│ 14:23:12 You Rp 2,300,000,000 │
│ 14:22:58 Bidder #3 Rp 2,250,000,000 │
│ 14:22:30 Bidder #7 Rp 2,200,000,000 │
│ 14:21:15 You Rp 2,150,000,000 │
│ ... (scrollable) │
│ │
│ 👥 8 Active Bidders │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Your Bid: Rp [____________] juta │ │
│ │ │ │
│ │ Minimum: Rp 2,400,000,000 (+50 juta) │ │
│ │ │ │
│ │ [ 🔨 Place Bid ] [ 🤖 Auto-bid up to ___ ] │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
WebSocket Events untuk Auction:
AUCTION EVENTS:
BIDDING FLOW:
├── CLIENT: place_bid
│ └── { auctionId, amount, userId }
│
├── SERVER: bid_accepted
│ └── { bidId, amount, bidderId, timestamp }
│ └── Broadcast ke SEMUA peserta
│
├── SERVER: bid_rejected
│ └── { reason: "Below minimum" | "Auction ended" }
│ └── Hanya ke bidder yang bersangkutan
│
├── SERVER: outbid_notification
│ └── { auctionId, newHighBid, yourBid }
│ └── Ke user yang di-outbid
│
├── SERVER: timer_sync
│ └── { auctionId, remainingSeconds }
│ └── Setiap 10 detik untuk sync
│
├── SERVER: time_extended
│ └── { auctionId, newEndTime, reason }
│ └── Jika ada bid di last 2 menit
│
└── SERVER: auction_ended
└── { auctionId, winnerId, finalPrice }
└── Broadcast ke semua
PRESENCE:
├── SERVER: bidder_joined
│ └── { auctionId, bidderCount }
├── SERVER: bidder_left
│ └── { auctionId, bidderCount }
└── Display: "👥 8 Active Bidders"
Critical Requirements:
AUCTION WEBSOCKET REQUIREMENTS:
1. CONSISTENCY
├── Semua bidder harus lihat bid yang sama
├── Order harus identical
└── No race conditions
2. FAIRNESS
├── Delay harus < 100ms untuk semua
├── Timer synchronized
└── Extension rules clear
3. RELIABILITY
├── Reconnection otomatis
├── Bid queue jika disconnect
└── Confirmation untuk setiap bid
4. SECURITY
├── Authenticated bidders only
├── Rate limiting per user
└── Audit trail untuk semua bids
FITUR #4: Live Presence & Social Proof
"FOMO" (Fear Of Missing Out) adalah psychological trigger yang powerful. Showing real-time interest creates urgency.
Implementation:
┌─────────────────────────────────────────────────────────┐
│ │
│ 🏠 Rumah Mewah Kemang │
│ Rp 8.5 Miliar • 4 BR • 350 m² │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 👀 12 orang sedang melihat properti ini │ │
│ │ ❤️ 8 orang menyimpan ke wishlist hari ini │ │
│ │ 📅 3 viewing sudah dijadwalkan minggu ini │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Agent: Budi Santoso │ │
│ │ 🟢 Online • Biasanya reply dalam 5 menit │ │
│ │ │ │
│ │ [ 💬 Chat Sekarang ] │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
LIVE UPDATES:
─────────────
• Counter "12 orang" berubah real-time saat user join/leave
• Wishlist count update saat ada yang save
• Agent status berubah saat online/offline
WebSocket Events:
PRESENCE EVENTS:
USER VIEWING PROPERTY:
├── CLIENT: join_property_view
│ └── { propertyId }
├── CLIENT: leave_property_view
│ └── { propertyId }
│
├── SERVER: viewer_count_update
│ └── { propertyId, count: 12 }
└── SERVER: activity_update
└── { propertyId, wishlistToday: 8, viewingsThisWeek: 3 }
AGENT STATUS:
├── SERVER: agent_online
│ └── { agentId, propertyIds[] }
├── SERVER: agent_offline
│ └── { agentId, lastSeen }
└── SERVER: agent_busy
└── { agentId, estimatedAvailable }
Psychological Impact:
SOCIAL PROOF PSYCHOLOGY:
"12 orang sedang melihat" →
├── Scarcity: "Banyak yang interest, bisa keburu"
├── Social validation: "Kalau banyak yang lihat, pasti bagus"
├── Urgency: "Harus action sekarang"
└── Trust: "Platform ini aktif dan legitimate"
CONVERSION IMPACT:
├── Without presence indicators: 3% inquiry rate
├── With "X people viewing": 7% inquiry rate (+133%)
├── With agent online status: 12% inquiry rate (+300%)
└── Combined: 15-20% inquiry rate (+400-500%)
FITUR #5: Instant Notifications
Notifikasi yang tepat waktu bisa membuat perbedaan antara dapat rumah impian atau kehilangan kesempatan.
Notification Types:
┌─────────────────────────────────────────────────────────┐
│ 🔔 NOTIFICATION CENTER │
├─────────────────────────────────────────────────────────┤
│ │
│ 🆕 NEW (3) │
│ ────────── │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 🏠 New listing matches your search! │ │
│ │ Rumah di Kemang < 5M, 4 BR │ │
│ │ Just listed • View now → │ │
│ │ 2 min │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 💰 Price drop on your wishlist! │ │
│ │ Rumah Pondok Indah: 5.5M → 5.2M (-5%) │ │
│ │ 15 min │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 💬 Agent replied to your inquiry │ │
│ │ Budi: "Bisa survey besok jam 10 Pak" │ │
│ │ 32 min │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ EARLIER │
│ ─────── │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 📅 Reminder: Viewing tomorrow │ │
│ │ Rumah Kemang • Tomorrow, 10:00 AM │ │
│ │ [ Add to Calendar ] [ Get Directions ] │ │
│ │ Yesterday │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
Notification Events:
NOTIFICATION CATEGORIES:
PROPERTY ALERTS:
├── new_listing_match
│ └── Matches saved search criteria
├── price_drop
│ └── Wishlist item price decreased
├── status_change
│ └── Wishlist item status changed
└── open_house_announced
└── Property hosting open house
COMMUNICATION:
├── new_message
│ └── Agent atau user mengirim pesan
├── inquiry_response
│ └── Agent membalas inquiry
└── document_ready
└── Dokumen tersedia untuk download
APPOINTMENTS:
├── viewing_reminder
│ └── H-1 dan H-3 jam sebelum
├── viewing_confirmed
│ └── Agent confirm jadwal
└── viewing_rescheduled
└── Jadwal berubah
TRANSACTION:
├── offer_received
│ └── Counter offer dari seller
├── offer_accepted
│ └── Offer diterima
├── document_required
│ └── Perlu upload dokumen
└── payment_reminder
└── Deadline pembayaran
Smart Notification Logic:
// Notification priority dan batching
const NOTIFICATION_CONFIG = {
// High priority - kirim instant
instant: [
'outbid', // Auction - urgent!
'offer_accepted', // Transaction milestone
'agent_reply', // Chat reply
],
// Medium priority - batch per 5 menit
batched: [
'new_listing_match',
'price_drop',
'viewing_reminder',
],
// Low priority - batch per 1 jam atau digest
digest: [
'similar_properties',
'market_update',
'tips_and_guides',
],
};
// Jangan spam user
const RATE_LIMITS = {
max_per_hour: 10,
max_per_day: 30,
quiet_hours: { start: 22, end: 8 }, // 10 PM - 8 AM
};
Summary: 5 Fitur Real-time
┌─────────────────────────────────────────────────────────────────────┐
│ FITUR REAL-TIME SUMMARY │
├─────────────────┬─────────────────────┬─────────────────────────────┤
│ Fitur │ WebSocket Events │ Business Impact │
├─────────────────┼─────────────────────┼─────────────────────────────┤
│ 💬 Live Chat │ message, typing, │ 5x faster response time │
│ │ read, online status │ 3x higher conversion │
├─────────────────┼─────────────────────┼─────────────────────────────┤
│ 🏠 Property │ status_changed, │ No stale data │
│ Updates │ price_updated │ Better UX, less frustration │
├─────────────────┼─────────────────────┼─────────────────────────────┤
│ 🔨 Live │ bid, outbid, │ Fair & transparent │
│ Auction │ timer_sync, ended │ Trust & engagement │
├─────────────────┼─────────────────────┼─────────────────────────────┤
│ 👀 Presence │ viewer_count, │ 4-5x inquiry rate │
│ │ agent_status │ Social proof & urgency │
├─────────────────┼─────────────────────┼─────────────────────────────┤
│ 🔔 Notif │ Various alerts │ First-mover advantage │
│ │ │ User retention │
└─────────────────┴─────────────────────┴─────────────────────────────┘
Di bagian selanjutnya, kita akan mulai implementasi backend dengan Laravel Reverb — first-party WebSocket solution untuk Laravel ecosystem.
Bagian 4: Implementasi Backend — Laravel Reverb
Laravel Reverb adalah first-party WebSocket server untuk Laravel. Diperkenalkan di Laravel 11, Reverb menjadi solusi native yang terintegrasi sempurna dengan Laravel ecosystem.
Kenapa Laravel Reverb?
KEUNTUNGAN LARAVEL REVERB:
├── 🏠 First-party Package
│ └── Dibuat oleh Laravel team, support jangka panjang
│
├── 🔌 Drop-in Replacement
│ └── Ganti Pusher tanpa ubah frontend code
│
├── 💰 Self-hosted
│ └── Tidak ada biaya per message seperti Pusher
│
├── ⚡ Performance
│ └── Built di atas ReactPHP, sangat cepat
│
├── 🔧 Easy Configuration
│ └── Familiar Laravel configuration style
│
└── 📈 Horizontal Scaling
└── Support Redis untuk multi-server setup
Step 1: Installation
# Install Reverb
composer require laravel/reverb
# Publish config dan run migration
php artisan reverb:install
# Ini akan:
# - Publish config/reverb.php
# - Publish config/broadcasting.php updates
# - Create necessary migrations
# .env configuration
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=rumahku
REVERB_APP_KEY=rumahku-app-key
REVERB_APP_SECRET=rumahku-secret-key
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http
# Untuk production
# REVERB_HOST=ws.rumahku.com
# REVERB_PORT=443
# REVERB_SCHEME=https
Step 2: Configure Broadcasting
<?php
// config/broadcasting.php
return [
'default' => env('BROADCAST_CONNECTION', 'reverb'),
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options
],
],
],
];
Step 3: Create Events untuk Platform Properti
Event 1: Chat Message
<?php
// app/Events/Chat/NewChatMessage.php
namespace App\\Events\\Chat;
use App\\Models\\Message;
use App\\Models\\User;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Broadcasting\\PrivateChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class NewChatMessage implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Message $message,
public User $sender,
public int $conversationId
) {}
/**
* Channel untuk broadcast - Private karena chat pribadi
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("conversation.{$this->conversationId}"),
];
}
/**
* Nama event yang diterima frontend
*/
public function broadcastAs(): string
{
return 'message.new';
}
/**
* Data yang dikirim ke frontend
*/
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'content' => $this->message->content,
'sender' => [
'id' => $this->sender->id,
'name' => $this->sender->name,
'avatar' => $this->sender->avatar_url,
],
'attachments' => $this->message->attachments,
'created_at' => $this->message->created_at->toISOString(),
];
}
}
Event 2: User Typing
<?php
// app/Events/Chat/UserTyping.php
namespace App\\Events\\Chat;
use Illuminate\\Broadcasting\\PrivateChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
class UserTyping implements ShouldBroadcast
{
public function __construct(
public int $conversationId,
public int $userId,
public string $userName,
public bool $isTyping = true
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel("conversation.{$this->conversationId}"),
];
}
public function broadcastAs(): string
{
return $this->isTyping ? 'user.typing' : 'user.stopped_typing';
}
public function broadcastWith(): array
{
return [
'user_id' => $this->userId,
'user_name' => $this->userName,
];
}
}
Event 3: Property Status Changed
<?php
// app/Events/Property/PropertyStatusChanged.php
namespace App\\Events\\Property;
use App\\Models\\Property;
use Illuminate\\Broadcasting\\Channel;
use Illuminate\\Broadcasting\\PrivateChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
class PropertyStatusChanged implements ShouldBroadcast
{
public function __construct(
public Property $property,
public string $oldStatus,
public string $newStatus
) {}
public function broadcastOn(): array
{
return [
// Public channel untuk semua yang sedang view
new Channel("property.{$this->property->id}"),
// Private channel untuk users yang wishlist
new PrivateChannel("property.{$this->property->id}.watchers"),
];
}
public function broadcastAs(): string
{
return 'status.changed';
}
public function broadcastWith(): array
{
return [
'property_id' => $this->property->id,
'title' => $this->property->title,
'old_status' => $this->oldStatus,
'new_status' => $this->newStatus,
'updated_at' => now()->toISOString(),
];
}
}
Event 4: Price Updated
<?php
// app/Events/Property/PriceUpdated.php
namespace App\\Events\\Property;
use App\\Models\\Property;
use Illuminate\\Broadcasting\\Channel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
class PriceUpdated implements ShouldBroadcast
{
public float $percentageChange;
public function __construct(
public Property $property,
public float $oldPrice,
public float $newPrice
) {
$this->percentageChange = (($newPrice - $oldPrice) / $oldPrice) * 100;
}
public function broadcastOn(): array
{
return [
new Channel("property.{$this->property->id}"),
];
}
public function broadcastAs(): string
{
return 'price.updated';
}
public function broadcastWith(): array
{
return [
'property_id' => $this->property->id,
'title' => $this->property->title,
'old_price' => $this->oldPrice,
'new_price' => $this->newPrice,
'percentage_change' => round($this->percentageChange, 1),
'is_price_drop' => $this->newPrice < $this->oldPrice,
];
}
}
Event 5: New Bid (Auction)
<?php
// app/Events/Auction/NewBid.php
namespace App\\Events\\Auction;
use App\\Models\\Auction;
use App\\Models\\Bid;
use Illuminate\\Broadcasting\\PresenceChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
class NewBid implements ShouldBroadcast
{
public function __construct(
public Auction $auction,
public Bid $bid,
public int $bidderNumber // Anonymous identifier
) {}
public function broadcastOn(): array
{
// Presence channel untuk track active bidders
return [
new PresenceChannel("auction.{$this->auction->id}"),
];
}
public function broadcastAs(): string
{
return 'bid.new';
}
public function broadcastWith(): array
{
return [
'auction_id' => $this->auction->id,
'bid' => [
'id' => $this->bid->id,
'amount' => $this->bid->amount,
'bidder_number' => $this->bidderNumber,
'created_at' => $this->bid->created_at->toISOString(),
],
'auction_state' => [
'current_price' => $this->bid->amount,
'minimum_next_bid' => $this->bid->amount + $this->auction->bid_increment,
'ends_at' => $this->auction->ends_at->toISOString(),
],
];
}
}
Event 6: Outbid Notification
<?php
// app/Events/Auction/UserOutbid.php
namespace App\\Events\\Auction;
use App\\Models\\Auction;
use Illuminate\\Broadcasting\\PrivateChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
class UserOutbid implements ShouldBroadcast
{
public function __construct(
public Auction $auction,
public int $userId,
public float $userBid,
public float $newHighBid
) {}
public function broadcastOn(): array
{
// Private - hanya ke user yang di-outbid
return [
new PrivateChannel("user.{$this->userId}.auctions"),
];
}
public function broadcastAs(): string
{
return 'outbid';
}
public function broadcastWith(): array
{
return [
'auction_id' => $this->auction->id,
'property_title' => $this->auction->property->title,
'your_bid' => $this->userBid,
'new_high_bid' => $this->newHighBid,
'minimum_to_win' => $this->newHighBid + $this->auction->bid_increment,
];
}
}
Step 4: Channel Authorization
<?php
// routes/channels.php
use App\\Models\\Conversation;
use App\\Models\\Auction;
use App\\Models\\Property;
use Illuminate\\Support\\Facades\\Broadcast;
/**
* Private channel untuk conversations
*/
Broadcast::channel('conversation.{conversationId}', function ($user, $conversationId) {
$conversation = Conversation::find($conversationId);
if (!$conversation) {
return false;
}
// User harus participant dalam conversation
return $conversation->participants->contains('id', $user->id);
});
/**
* Private channel untuk property watchers
*/
Broadcast::channel('property.{propertyId}.watchers', function ($user, $propertyId) {
// User harus sudah wishlist property ini
return $user->wishlist()->where('property_id', $propertyId)->exists();
});
/**
* Private channel untuk user-specific notifications
*/
Broadcast::channel('user.{userId}.auctions', function ($user, $userId) {
return (int) $user->id === (int) $userId;
});
/**
* Presence channel untuk auctions
*/
Broadcast::channel('auction.{auctionId}', function ($user, $auctionId) {
$auction = Auction::find($auctionId);
if (!$auction || !$auction->is_active) {
return false;
}
// Return user info untuk presence
return [
'id' => $user->id,
'bidder_number' => $auction->getBidderNumber($user->id),
];
});
/**
* Presence channel untuk property viewers
*/
Broadcast::channel('property.{propertyId}.viewers', function ($user, $propertyId) {
return [
'id' => $user->id,
'name' => $user->name,
];
});
Step 5: Controllers dan Broadcasting
<?php
// app/Http/Controllers/ChatController.php
namespace App\\Http\\Controllers;
use App\\Events\\Chat\\NewChatMessage;
use App\\Events\\Chat\\UserTyping;
use App\\Models\\Conversation;
use App\\Models\\Message;
use Illuminate\\Http\\Request;
class ChatController extends Controller
{
/**
* Kirim message
*/
public function sendMessage(Request $request, Conversation $conversation)
{
$this->authorize('participate', $conversation);
$validated = $request->validate([
'content' => 'required|string|max:5000',
'attachments' => 'array|max:5',
'attachments.*' => 'file|max:10240', // 10MB
]);
$message = $conversation->messages()->create([
'user_id' => $request->user()->id,
'content' => $validated['content'],
'attachments' => $this->handleAttachments($request),
]);
// Broadcast ke semua participants
broadcast(new NewChatMessage(
message: $message,
sender: $request->user(),
conversationId: $conversation->id
))->toOthers(); // Exclude sender
return response()->json([
'message' => $message->load('user'),
], 201);
}
/**
* Typing indicator
*/
public function typing(Request $request, Conversation $conversation)
{
$this->authorize('participate', $conversation);
broadcast(new UserTyping(
conversationId: $conversation->id,
userId: $request->user()->id,
userName: $request->user()->name,
isTyping: $request->boolean('is_typing', true)
))->toOthers();
return response()->json(['status' => 'ok']);
}
}
<?php
// app/Http/Controllers/AuctionController.php
namespace App\\Http\\Controllers;
use App\\Events\\Auction\\NewBid;
use App\\Events\\Auction\\UserOutbid;
use App\\Models\\Auction;
use App\\Models\\Bid;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\DB;
class AuctionController extends Controller
{
/**
* Place a bid
*/
public function placeBid(Request $request, Auction $auction)
{
$validated = $request->validate([
'amount' => 'required|numeric|min:0',
]);
// Validate auction is active
if (!$auction->is_active || $auction->ends_at->isPast()) {
return response()->json([
'error' => 'Auction has ended',
], 422);
}
// Validate minimum bid
$minimumBid = $auction->current_price + $auction->bid_increment;
if ($validated['amount'] < $minimumBid) {
return response()->json([
'error' => "Minimum bid is " . number_format($minimumBid),
], 422);
}
return DB::transaction(function () use ($request, $auction, $validated) {
// Get previous highest bidder
$previousHighBid = $auction->highestBid;
// Create new bid
$bid = $auction->bids()->create([
'user_id' => $request->user()->id,
'amount' => $validated['amount'],
]);
// Update auction current price
$auction->update([
'current_price' => $validated['amount'],
]);
// Extend auction if bid in last 2 minutes
if ($auction->ends_at->diffInMinutes(now()) < 2) {
$auction->update([
'ends_at' => $auction->ends_at->addMinutes(2),
]);
}
// Broadcast new bid to all participants
$bidderNumber = $auction->getBidderNumber($request->user()->id);
broadcast(new NewBid($auction->fresh(), $bid, $bidderNumber));
// Notify previous highest bidder that they've been outbid
if ($previousHighBid && $previousHighBid->user_id !== $request->user()->id) {
broadcast(new UserOutbid(
auction: $auction,
userId: $previousHighBid->user_id,
userBid: $previousHighBid->amount,
newHighBid: $validated['amount']
));
}
return response()->json([
'bid' => $bid,
'auction' => $auction->fresh(),
], 201);
});
}
}
Step 6: Running Reverb Server
# Development
php artisan reverb:start
# Dengan options
php artisan reverb:start --host=0.0.0.0 --port=8080 --debug
# Production (dengan Supervisor)
php artisan reverb:start --host=0.0.0.0 --port=8080
# /etc/supervisor/conf.d/reverb.conf
[program:reverb]
process_name=%(program_name)s
command=php /var/www/rumahku/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/log/reverb.log
stopwaitsecs=60
Laravel Reverb sekarang ready! Di bagian selanjutnya, kita lihat implementasi yang sama dengan Node.js dan Socket.io.
Bagian 5: Implementasi Backend — Node.js dengan Socket.io
Socket.io adalah library WebSocket paling populer untuk Node.js. Dengan fitur auto-reconnection, room management, dan fallback ke long-polling, Socket.io menjadi pilihan solid untuk production.
Kenapa Socket.io?
KEUNTUNGAN SOCKET.IO:
├── 🔄 Auto Fallback
│ └── Jika WebSocket gagal, fallback ke polling otomatis
│
├── 🏠 Room & Namespace
│ └── Built-in support untuk grouping connections
│
├── 🔌 Reconnection
│ └── Auto reconnect dengan exponential backoff
│
├── 📦 Universal
│ └── Bisa dipakai dengan framework apapun
│
├── 🌍 Cross-platform
│ └── Clients untuk web, mobile, desktop
│
└── 📈 Proven at Scale
└── Dipakai oleh Trello, Microsoft, dll
Step 1: Project Setup
# Initialize project
mkdir rumahku-websocket
cd rumahku-websocket
npm init -y
# Install dependencies
npm install express socket.io cors dotenv jsonwebtoken redis
npm install -D nodemon typescript @types/node
// package.json scripts
{
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js"
}
}
Step 2: Server Setup
// src/server.js
require('dotenv').config();
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const app = express();
const httpServer = createServer(app);
// Socket.io dengan CORS
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL || '<http://localhost:3000>',
methods: ['GET', 'POST'],
credentials: true,
},
pingTimeout: 60000,
pingInterval: 25000,
});
app.use(cors());
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
connections: io.engine.clientsCount
});
});
// Authentication middleware untuk Socket.io
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication required'));
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
socket.user = decoded;
next();
} catch (error) {
next(new Error('Invalid token'));
}
});
// Import handlers
const chatHandler = require('./handlers/chat');
const propertyHandler = require('./handlers/property');
const auctionHandler = require('./handlers/auction');
const presenceHandler = require('./handlers/presence');
// Connection handler
io.on('connection', (socket) => {
console.log(`User connected: ${socket.user.id}`);
// Register handlers
chatHandler(io, socket);
propertyHandler(io, socket);
auctionHandler(io, socket);
presenceHandler(io, socket);
// Disconnect handler
socket.on('disconnect', (reason) => {
console.log(`User disconnected: ${socket.user.id}, reason: ${reason}`);
});
});
const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`WebSocket server running on port ${PORT}`);
});
module.exports = { io };
Step 3: Chat Handler
// src/handlers/chat.js
const Message = require('../models/Message');
const Conversation = require('../models/Conversation');
module.exports = (io, socket) => {
/**
* Join conversation room
*/
socket.on('chat:join', async (conversationId) => {
try {
// Verify user is participant
const conversation = await Conversation.findById(conversationId);
if (!conversation) {
return socket.emit('error', { message: 'Conversation not found' });
}
const isParticipant = conversation.participants.some(
p => p.userId === socket.user.id
);
if (!isParticipant) {
return socket.emit('error', { message: 'Not authorized' });
}
// Join room
socket.join(`conversation:${conversationId}`);
// Notify others
socket.to(`conversation:${conversationId}`).emit('chat:user_joined', {
userId: socket.user.id,
userName: socket.user.name,
});
console.log(`User ${socket.user.id} joined conversation ${conversationId}`);
} catch (error) {
console.error('Error joining conversation:', error);
socket.emit('error', { message: 'Failed to join conversation' });
}
});
/**
* Leave conversation room
*/
socket.on('chat:leave', (conversationId) => {
socket.leave(`conversation:${conversationId}`);
socket.to(`conversation:${conversationId}`).emit('chat:user_left', {
userId: socket.user.id,
});
});
/**
* Send message
*/
socket.on('chat:send_message', async (data) => {
const { conversationId, content, attachments = [] } = data;
try {
// Create message in database
const message = await Message.create({
conversationId,
senderId: socket.user.id,
content,
attachments,
});
// Populate sender info
await message.populate('sender', 'id name avatar');
// Broadcast to room (including sender for confirmation)
io.to(`conversation:${conversationId}`).emit('chat:new_message', {
id: message._id,
conversationId,
content: message.content,
sender: {
id: message.sender.id,
name: message.sender.name,
avatar: message.sender.avatar,
},
attachments: message.attachments,
createdAt: message.createdAt,
});
// Update conversation last message
await Conversation.findByIdAndUpdate(conversationId, {
lastMessage: message._id,
lastMessageAt: new Date(),
});
} catch (error) {
console.error('Error sending message:', error);
socket.emit('error', { message: 'Failed to send message' });
}
});
/**
* Typing indicator
*/
socket.on('chat:typing_start', (conversationId) => {
socket.to(`conversation:${conversationId}`).emit('chat:user_typing', {
userId: socket.user.id,
userName: socket.user.name,
});
});
socket.on('chat:typing_stop', (conversationId) => {
socket.to(`conversation:${conversationId}`).emit('chat:user_stopped_typing', {
userId: socket.user.id,
});
});
/**
* Mark messages as read
*/
socket.on('chat:mark_read', async ({ conversationId, messageId }) => {
try {
await Message.updateMany(
{
conversationId,
_id: { $lte: messageId },
senderId: { $ne: socket.user.id },
readBy: { $ne: socket.user.id },
},
{
$addToSet: { readBy: socket.user.id },
$set: { readAt: new Date() },
}
);
socket.to(`conversation:${conversationId}`).emit('chat:messages_read', {
conversationId,
readBy: socket.user.id,
upToMessageId: messageId,
});
} catch (error) {
console.error('Error marking as read:', error);
}
});
};
Step 4: Property Handler
// src/handlers/property.js
const Property = require('../models/Property');
// Track viewers per property
const propertyViewers = new Map();
module.exports = (io, socket) => {
/**
* Start viewing property
*/
socket.on('property:view', async (propertyId) => {
try {
// Join property room
socket.join(`property:${propertyId}`);
// Track viewer
if (!propertyViewers.has(propertyId)) {
propertyViewers.set(propertyId, new Set());
}
propertyViewers.get(propertyId).add(socket.user.id);
// Store which property this socket is viewing
socket.viewingProperty = propertyId;
// Broadcast updated viewer count
const viewerCount = propertyViewers.get(propertyId).size;
io.to(`property:${propertyId}`).emit('property:viewer_count', {
propertyId,
count: viewerCount,
});
console.log(`User ${socket.user.id} viewing property ${propertyId}, total: ${viewerCount}`);
} catch (error) {
console.error('Error viewing property:', error);
}
});
/**
* Stop viewing property
*/
socket.on('property:leave', (propertyId) => {
handleLeaveProperty(io, socket, propertyId);
});
// Also handle on disconnect
socket.on('disconnect', () => {
if (socket.viewingProperty) {
handleLeaveProperty(io, socket, socket.viewingProperty);
}
});
/**
* Subscribe to property updates (wishlist)
*/
socket.on('property:subscribe', async (propertyId) => {
socket.join(`property:${propertyId}:watchers`);
console.log(`User ${socket.user.id} subscribed to property ${propertyId}`);
});
socket.on('property:unsubscribe', (propertyId) => {
socket.leave(`property:${propertyId}:watchers`);
});
};
function handleLeaveProperty(io, socket, propertyId) {
socket.leave(`property:${propertyId}`);
if (propertyViewers.has(propertyId)) {
propertyViewers.get(propertyId).delete(socket.user.id);
const viewerCount = propertyViewers.get(propertyId).size;
io.to(`property:${propertyId}`).emit('property:viewer_count', {
propertyId,
count: viewerCount,
});
// Cleanup empty sets
if (viewerCount === 0) {
propertyViewers.delete(propertyId);
}
}
socket.viewingProperty = null;
}
// Export function untuk broadcast dari external (API)
module.exports.broadcastPropertyUpdate = (io, propertyId, eventType, data) => {
io.to(`property:${propertyId}`).emit(`property:${eventType}`, {
propertyId,
...data,
});
// Also notify watchers
io.to(`property:${propertyId}:watchers`).emit(`property:${eventType}`, {
propertyId,
...data,
});
};
Step 5: Auction Handler
// src/handlers/auction.js
const Auction = require('../models/Auction');
const Bid = require('../models/Bid');
// Track active bidders per auction
const auctionBidders = new Map();
const bidderNumbers = new Map(); // auctionId -> Map(userId -> number)
module.exports = (io, socket) => {
/**
* Join auction
*/
socket.on('auction:join', async (auctionId) => {
try {
const auction = await Auction.findById(auctionId);
if (!auction) {
return socket.emit('error', { message: 'Auction not found' });
}
if (!auction.isActive) {
return socket.emit('error', { message: 'Auction has ended' });
}
// Join auction room
socket.join(`auction:${auctionId}`);
// Track bidder
if (!auctionBidders.has(auctionId)) {
auctionBidders.set(auctionId, new Set());
bidderNumbers.set(auctionId, new Map());
}
auctionBidders.get(auctionId).add(socket.user.id);
// Assign bidder number if new
if (!bidderNumbers.get(auctionId).has(socket.user.id)) {
const number = bidderNumbers.get(auctionId).size + 1;
bidderNumbers.get(auctionId).set(socket.user.id, number);
}
socket.auctionId = auctionId;
// Send current auction state
const bids = await Bid.find({ auctionId })
.sort({ createdAt: -1 })
.limit(20);
socket.emit('auction:state', {
auction: {
id: auction._id,
currentPrice: auction.currentPrice,
minimumBid: auction.currentPrice + auction.bidIncrement,
bidIncrement: auction.bidIncrement,
endsAt: auction.endsAt,
},
recentBids: bids.map(b => ({
id: b._id,
amount: b.amount,
bidderNumber: bidderNumbers.get(auctionId).get(b.userId),
createdAt: b.createdAt,
})),
yourBidderNumber: bidderNumbers.get(auctionId).get(socket.user.id),
activeBidders: auctionBidders.get(auctionId).size,
});
// Broadcast bidder count update
io.to(`auction:${auctionId}`).emit('auction:bidder_count', {
count: auctionBidders.get(auctionId).size,
});
} catch (error) {
console.error('Error joining auction:', error);
socket.emit('error', { message: 'Failed to join auction' });
}
});
/**
* Place bid
*/
socket.on('auction:bid', async ({ auctionId, amount }) => {
try {
const auction = await Auction.findById(auctionId);
// Validations
if (!auction || !auction.isActive) {
return socket.emit('auction:bid_rejected', {
reason: 'Auction is not active',
});
}
if (new Date() > auction.endsAt) {
return socket.emit('auction:bid_rejected', {
reason: 'Auction has ended',
});
}
const minimumBid = auction.currentPrice + auction.bidIncrement;
if (amount < minimumBid) {
return socket.emit('auction:bid_rejected', {
reason: `Minimum bid is ${minimumBid.toLocaleString()}`,
});
}
// Get previous high bidder
const previousHighBid = await Bid.findOne({ auctionId })
.sort({ amount: -1 });
// Create bid
const bid = await Bid.create({
auctionId,
userId: socket.user.id,
amount,
});
// Update auction
auction.currentPrice = amount;
// Extend if bid in last 2 minutes
const timeRemaining = (auction.endsAt - new Date()) / 1000 / 60;
if (timeRemaining < 2) {
auction.endsAt = new Date(auction.endsAt.getTime() + 2 * 60 * 1000);
}
await auction.save();
// Broadcast new bid to all
io.to(`auction:${auctionId}`).emit('auction:new_bid', {
bid: {
id: bid._id,
amount: bid.amount,
bidderNumber: bidderNumbers.get(auctionId).get(socket.user.id),
createdAt: bid.createdAt,
},
auction: {
currentPrice: auction.currentPrice,
minimumBid: auction.currentPrice + auction.bidIncrement,
endsAt: auction.endsAt,
},
});
// Notify outbid user
if (previousHighBid && previousHighBid.userId !== socket.user.id) {
const outbidSocket = [...io.sockets.sockets.values()]
.find(s => s.user?.id === previousHighBid.userId);
if (outbidSocket) {
outbidSocket.emit('auction:outbid', {
auctionId,
yourBid: previousHighBid.amount,
newHighBid: amount,
minimumToWin: amount + auction.bidIncrement,
});
}
}
// Confirm to bidder
socket.emit('auction:bid_accepted', {
bidId: bid._id,
amount: bid.amount,
});
} catch (error) {
console.error('Error placing bid:', error);
socket.emit('auction:bid_rejected', {
reason: 'Failed to place bid',
});
}
});
/**
* Leave auction
*/
socket.on('auction:leave', (auctionId) => {
handleLeaveAuction(io, socket, auctionId);
});
socket.on('disconnect', () => {
if (socket.auctionId) {
handleLeaveAuction(io, socket, socket.auctionId);
}
});
};
function handleLeaveAuction(io, socket, auctionId) {
socket.leave(`auction:${auctionId}`);
if (auctionBidders.has(auctionId)) {
auctionBidders.get(auctionId).delete(socket.user.id);
io.to(`auction:${auctionId}`).emit('auction:bidder_count', {
count: auctionBidders.get(auctionId).size,
});
}
socket.auctionId = null;
}
// Timer sync - broadcast setiap 10 detik
setInterval(async () => {
for (const [auctionId, bidders] of auctionBidders) {
if (bidders.size > 0) {
try {
const auction = await Auction.findById(auctionId);
if (auction && auction.isActive) {
io.to(`auction:${auctionId}`).emit('auction:timer_sync', {
endsAt: auction.endsAt,
serverTime: new Date(),
});
}
} catch (error) {
console.error('Timer sync error:', error);
}
}
}
}, 10000);
Step 6: Presence Handler
// src/handlers/presence.js
// Track online agents
const onlineAgents = new Map(); // agentId -> { socketId, lastSeen }
module.exports = (io, socket) => {
/**
* Agent comes online
*/
socket.on('presence:agent_online', () => {
if (socket.user.role !== 'agent') {
return;
}
onlineAgents.set(socket.user.id, {
socketId: socket.id,
lastSeen: new Date(),
});
// Broadcast to relevant rooms
socket.user.propertyIds?.forEach(propertyId => {
io.to(`property:${propertyId}`).emit('presence:agent_status', {
agentId: socket.user.id,
status: 'online',
});
});
console.log(`Agent ${socket.user.id} is online`);
});
/**
* Agent goes offline
*/
socket.on('disconnect', () => {
if (socket.user.role === 'agent' && onlineAgents.has(socket.user.id)) {
onlineAgents.delete(socket.user.id);
socket.user.propertyIds?.forEach(propertyId => {
io.to(`property:${propertyId}`).emit('presence:agent_status', {
agentId: socket.user.id,
status: 'offline',
lastSeen: new Date(),
});
});
console.log(`Agent ${socket.user.id} is offline`);
}
});
/**
* Check agent status
*/
socket.on('presence:check_agent', (agentId) => {
const agent = onlineAgents.get(agentId);
socket.emit('presence:agent_status', {
agentId,
status: agent ? 'online' : 'offline',
lastSeen: agent?.lastSeen || null,
});
});
};
// Export helper
module.exports.isAgentOnline = (agentId) => onlineAgents.has(agentId);
Step 7: Scaling dengan Redis Adapter
// src/server.js (updated untuk scaling)
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
async function setupRedisAdapter(io) {
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
console.log('Redis adapter connected');
}
// Call setelah io dibuat
setupRedisAdapter(io).catch(console.error);
# .env
REDIS_URL=redis://localhost:6379
Step 8: API Integration
// src/routes/api.js - Untuk trigger broadcast dari REST API
const express = require('express');
const router = express.Router();
const { io } = require('../server');
const { broadcastPropertyUpdate } = require('../handlers/property');
/**
* Endpoint untuk update property (dipanggil dari main API)
*/
router.post('/broadcast/property-status', (req, res) => {
const { propertyId, oldStatus, newStatus, title } = req.body;
broadcastPropertyUpdate(io, propertyId, 'status_changed', {
title,
oldStatus,
newStatus,
updatedAt: new Date(),
});
res.json({ success: true });
});
router.post('/broadcast/price-update', (req, res) => {
const { propertyId, oldPrice, newPrice, title } = req.body;
broadcastPropertyUpdate(io, propertyId, 'price_updated', {
title,
oldPrice,
newPrice,
percentageChange: ((newPrice - oldPrice) / oldPrice * 100).toFixed(1),
isPriceDrop: newPrice < oldPrice,
});
res.json({ success: true });
});
module.exports = router;
Socket.io server sekarang ready untuk handle semua fitur real-time! Di bagian selanjutnya, kita implementasi dengan Golang untuk high-performance scenario.
Bagian 6: Implementasi Backend — Golang dengan Gorilla WebSocket
Untuk aplikasi yang membutuhkan maximum performance dan minimum resource usage, Golang adalah pilihan excellent. Dengan goroutines dan Gorilla WebSocket library, kita bisa handle ratusan ribu concurrent connections.
Kenapa Golang untuk WebSocket?
KEUNTUNGAN GOLANG:
├── ⚡ Exceptional Performance
│ └── Compiled language, very fast execution
│
├── 🧵 Goroutines
│ └── Lightweight threads, bisa spawn jutaan
│
├── 💾 Low Memory
│ └── ~2KB per goroutine vs ~1MB per OS thread
│
├── 🔄 Excellent Concurrency
│ └── Built-in channels untuk communication
│
├── 📦 Single Binary
│ └── Easy deployment, no runtime dependencies
│
└── 🏢 Production Proven
└── Dipakai Discord, Twitch, untuk WebSocket
Step 1: Project Setup
# Initialize module
mkdir rumahku-ws-go
cd rumahku-ws-go
go mod init github.com/rumahku/websocket
# Install dependencies
go get github.com/gorilla/websocket
go get github.com/gorilla/mux
go get github.com/golang-jwt/jwt/v5
go get github.com/redis/go-redis/v9
Step 2: Hub Pattern — Central Connection Manager
// internal/hub/hub.go
package hub
import (
"sync"
"github.com/gorilla/websocket"
)
// Client represents a single WebSocket connection
type Client struct {
Hub *Hub
Conn *websocket.Conn
UserID string
UserName string
Send chan []byte
Rooms map[string]bool
mu sync.RWMutex
}
// Hub maintains active clients and broadcasts messages
type Hub struct {
// Registered clients
clients map[*Client]bool
// User ID to clients mapping (user bisa punya multiple connections)
userClients map[string]map[*Client]bool
// Room memberships
rooms map[string]map[*Client]bool
// Channels
register chan *Client
unregister chan *Client
broadcast chan *BroadcastMessage
roomcast chan *RoomMessage
mu sync.RWMutex
}
type BroadcastMessage struct {
Data []byte
}
type RoomMessage struct {
Room string
Data []byte
Exclude *Client // Optional: exclude sender
}
// NewHub creates a new Hub instance
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
userClients: make(map[string]map[*Client]bool),
rooms: make(map[string]map[*Client]bool),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan *BroadcastMessage),
roomcast: make(chan *RoomMessage),
}
}
// Run starts the hub's main loop
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.registerClient(client)
case client := <-h.unregister:
h.unregisterClient(client)
case message := <-h.broadcast:
h.broadcastToAll(message)
case message := <-h.roomcast:
h.broadcastToRoom(message)
}
}
}
func (h *Hub) registerClient(client *Client) {
h.mu.Lock()
defer h.mu.Unlock()
h.clients[client] = true
// Track by user ID
if _, ok := h.userClients[client.UserID]; !ok {
h.userClients[client.UserID] = make(map[*Client]bool)
}
h.userClients[client.UserID][client] = true
}
func (h *Hub) unregisterClient(client *Client) {
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.Send)
// Remove from user clients
if clients, ok := h.userClients[client.UserID]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.userClients, client.UserID)
}
}
// Remove from all rooms
for room := range client.Rooms {
if clients, ok := h.rooms[room]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.rooms, room)
}
}
}
}
}
func (h *Hub) broadcastToAll(message *BroadcastMessage) {
h.mu.RLock()
defer h.mu.RUnlock()
for client := range h.clients {
select {
case client.Send <- message.Data:
default:
// Client buffer full, skip
}
}
}
func (h *Hub) broadcastToRoom(message *RoomMessage) {
h.mu.RLock()
defer h.mu.RUnlock()
if clients, ok := h.rooms[message.Room]; ok {
for client := range clients {
if message.Exclude != nil && client == message.Exclude {
continue
}
select {
case client.Send <- message.Data:
default:
// Client buffer full
}
}
}
}
// JoinRoom adds client to a room
func (h *Hub) JoinRoom(client *Client, room string) {
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.rooms[room]; !ok {
h.rooms[room] = make(map[*Client]bool)
}
h.rooms[room][client] = true
client.mu.Lock()
client.Rooms[room] = true
client.mu.Unlock()
}
// LeaveRoom removes client from a room
func (h *Hub) LeaveRoom(client *Client, room string) {
h.mu.Lock()
defer h.mu.Unlock()
if clients, ok := h.rooms[room]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.rooms, room)
}
}
client.mu.Lock()
delete(client.Rooms, room)
client.mu.Unlock()
}
// GetRoomClientCount returns number of clients in a room
func (h *Hub) GetRoomClientCount(room string) int {
h.mu.RLock()
defer h.mu.RUnlock()
if clients, ok := h.rooms[room]; ok {
return len(clients)
}
return 0
}
// SendToUser sends message to all connections of a user
func (h *Hub) SendToUser(userID string, data []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
if clients, ok := h.userClients[userID]; ok {
for client := range clients {
select {
case client.Send <- data:
default:
}
}
}
}
// BroadcastToRoom sends message to all clients in a room
func (h *Hub) BroadcastToRoom(room string, data []byte, exclude *Client) {
h.roomcast <- &RoomMessage{
Room: room,
Data: data,
Exclude: exclude,
}
}
Step 3: Client Handler
// internal/hub/client.go
package hub
import (
"encoding/json"
"log"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 512 * 1024 // 512KB
)
// Message represents incoming WebSocket message
type Message struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
// ReadPump pumps messages from WebSocket to hub
func (c *Client) ReadPump(handlers map[string]MessageHandler) {
defer func() {
c.Hub.unregister <- c
c.Conn.Close()
}()
c.Conn.SetReadLimit(maxMessageSize)
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
c.Conn.SetPongHandler(func(string) error {
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, data, err := c.Conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket error: %v", err)
}
break
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("Invalid message format: %v", err)
continue
}
// Route to handler
if handler, ok := handlers[msg.Type]; ok {
go handler(c, msg.Payload)
} else {
log.Printf("Unknown message type: %s", msg.Type)
}
}
}
// WritePump pumps messages from hub to WebSocket
func (c *Client) WritePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.Send:
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.Conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// SendJSON sends JSON message to client
func (c *Client) SendJSON(msgType string, payload interface{}) error {
data, err := json.Marshal(map[string]interface{}{
"type": msgType,
"payload": payload,
})
if err != nil {
return err
}
select {
case c.Send <- data:
return nil
default:
return ErrBufferFull
}
}
var ErrBufferFull = fmt.Errorf("client buffer full")
// MessageHandler type
type MessageHandler func(client *Client, payload json.RawMessage)
Step 4: WebSocket Server
// cmd/server/main.go
package main
import (
"log"
"net/http"
"os"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/rumahku/websocket/internal/hub"
"github.com/rumahku/websocket/internal/handlers"
"github.com/rumahku/websocket/internal/auth"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
// Validate origin in production
allowedOrigins := []string{
"<http://localhost:3000>",
"<https://rumahku.com>",
}
for _, allowed := range allowedOrigins {
if origin == allowed {
return true
}
}
return false
},
}
func main() {
// Create hub
h := hub.NewHub()
go h.Run()
// Setup message handlers
messageHandlers := map[string]hub.MessageHandler{
// Chat
"chat:join": handlers.ChatJoin(h),
"chat:leave": handlers.ChatLeave(h),
"chat:send_message": handlers.ChatSendMessage(h),
"chat:typing": handlers.ChatTyping(h),
// Property
"property:view": handlers.PropertyView(h),
"property:leave": handlers.PropertyLeave(h),
"property:subscribe": handlers.PropertySubscribe(h),
// Auction
"auction:join": handlers.AuctionJoin(h),
"auction:bid": handlers.AuctionBid(h),
"auction:leave": handlers.AuctionLeave(h),
}
// Router
r := mux.NewRouter()
// WebSocket endpoint
r.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
// Authenticate
token := r.URL.Query().Get("token")
user, err := auth.ValidateToken(token)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Upgrade connection
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Upgrade error: %v", err)
return
}
// Create client
client := &hub.Client{
Hub: h,
Conn: conn,
UserID: user.ID,
UserName: user.Name,
Send: make(chan []byte, 256),
Rooms: make(map[string]bool),
}
// Register client
h.Register(client)
// Start pumps
go client.WritePump()
go client.ReadPump(messageHandlers)
log.Printf("Client connected: %s", user.ID)
})
// Health check
r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// API endpoints untuk broadcast dari external
r.HandleFunc("/api/broadcast/property", handlers.APIBroadcastProperty(h)).Methods("POST")
r.HandleFunc("/api/broadcast/auction", handlers.APIBroadcastAuction(h)).Methods("POST")
// Start server
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("WebSocket server starting on port %s", port)
if err := http.ListenAndServe(":"+port, r); err != nil {
log.Fatal(err)
}
}
Step 5: Message Handlers
// internal/handlers/chat.go
package handlers
import (
"encoding/json"
"fmt"
"time"
"github.com/rumahku/websocket/internal/hub"
"github.com/rumahku/websocket/internal/models"
)
// ChatJoin handles joining a conversation
func ChatJoin(h *hub.Hub) hub.MessageHandler {
return func(client *hub.Client, payload json.RawMessage) {
var data struct {
ConversationID string `json:"conversation_id"`
}
if err := json.Unmarshal(payload, &data); err != nil {
client.SendJSON("error", map[string]string{"message": "Invalid payload"})
return
}
// TODO: Validate user is participant (check database)
room := fmt.Sprintf("conversation:%s", data.ConversationID)
h.JoinRoom(client, room)
// Notify others
h.BroadcastToRoom(room, mustJSON(map[string]interface{}{
"type": "chat:user_joined",
"payload": map[string]interface{}{
"user_id": client.UserID,
"user_name": client.UserName,
},
}), client)
client.SendJSON("chat:joined", map[string]string{
"conversation_id": data.ConversationID,
})
}
}
// ChatSendMessage handles sending a message
func ChatSendMessage(h *hub.Hub) hub.MessageHandler {
return func(client *hub.Client, payload json.RawMessage) {
var data struct {
ConversationID string `json:"conversation_id"`
Content string `json:"content"`
Attachments []string `json:"attachments"`
}
if err := json.Unmarshal(payload, &data); err != nil {
client.SendJSON("error", map[string]string{"message": "Invalid payload"})
return
}
// TODO: Save to database
messageID := generateID()
room := fmt.Sprintf("conversation:%s", data.ConversationID)
// Broadcast to room
h.BroadcastToRoom(room, mustJSON(map[string]interface{}{
"type": "chat:new_message",
"payload": map[string]interface{}{
"id": messageID,
"conversation_id": data.ConversationID,
"content": data.Content,
"sender": map[string]interface{}{
"id": client.UserID,
"name": client.UserName,
},
"attachments": data.Attachments,
"created_at": time.Now().UTC().Format(time.RFC3339),
},
}), nil) // Include sender untuk confirmation
}
}
// ChatTyping handles typing indicator
func ChatTyping(h *hub.Hub) hub.MessageHandler {
return func(client *hub.Client, payload json.RawMessage) {
var data struct {
ConversationID string `json:"conversation_id"`
IsTyping bool `json:"is_typing"`
}
if err := json.Unmarshal(payload, &data); err != nil {
return
}
room := fmt.Sprintf("conversation:%s", data.ConversationID)
eventType := "chat:user_typing"
if !data.IsTyping {
eventType = "chat:user_stopped_typing"
}
h.BroadcastToRoom(room, mustJSON(map[string]interface{}{
"type": eventType,
"payload": map[string]interface{}{
"user_id": client.UserID,
"user_name": client.UserName,
},
}), client) // Exclude sender
}
}
func ChatLeave(h *hub.Hub) hub.MessageHandler {
return func(client *hub.Client, payload json.RawMessage) {
var data struct {
ConversationID string `json:"conversation_id"`
}
if err := json.Unmarshal(payload, &data); err != nil {
return
}
room := fmt.Sprintf("conversation:%s", data.ConversationID)
h.LeaveRoom(client, room)
h.BroadcastToRoom(room, mustJSON(map[string]interface{}{
"type": "chat:user_left",
"payload": map[string]interface{}{
"user_id": client.UserID,
},
}), nil)
}
}
// internal/handlers/auction.go
package handlers
import (
"encoding/json"
"fmt"
"sync"
"time"
"github.com/rumahku/websocket/internal/hub"
)
// Track auction state in memory (production: use Redis)
var (
auctionBidders = make(map[string]map[string]int) // auctionID -> userID -> bidderNumber
auctionMu sync.RWMutex
)
func AuctionJoin(h *hub.Hub) hub.MessageHandler {
return func(client *hub.Client, payload json.RawMessage) {
var data struct {
AuctionID string `json:"auction_id"`
}
if err := json.Unmarshal(payload, &data); err != nil {
client.SendJSON("error", map[string]string{"message": "Invalid payload"})
return
}
// TODO: Validate auction exists and is active
room := fmt.Sprintf("auction:%s", data.AuctionID)
h.JoinRoom(client, room)
// Assign bidder number
auctionMu.Lock()
if _, ok := auctionBidders[data.AuctionID]; !ok {
auctionBidders[data.AuctionID] = make(map[string]int)
}
bidderNumber, exists := auctionBidders[data.AuctionID][client.UserID]
if !exists {
bidderNumber = len(auctionBidders[data.AuctionID]) + 1
auctionBidders[data.AuctionID][client.UserID] = bidderNumber
}
auctionMu.Unlock()
// Send auction state
// TODO: Fetch from database
client.SendJSON("auction:state", map[string]interface{}{
"auction_id": data.AuctionID,
"bidder_number": bidderNumber,
"active_bidders": h.GetRoomClientCount(room),
})
// Broadcast bidder count
h.BroadcastToRoom(room, mustJSON(map[string]interface{}{
"type": "auction:bidder_count",
"payload": map[string]interface{}{
"count": h.GetRoomClientCount(room),
},
}), nil)
}
}
func AuctionBid(h *hub.Hub) hub.MessageHandler {
return func(client *hub.Client, payload json.RawMessage) {
var data struct {
AuctionID string `json:"auction_id"`
Amount float64 `json:"amount"`
}
if err := json.Unmarshal(payload, &data); err != nil {
client.SendJSON("auction:bid_rejected", map[string]string{
"reason": "Invalid payload",
})
return
}
// TODO: Validate bid (check minimum, auction active, etc)
// TODO: Save to database
// TODO: Get previous high bidder for outbid notification
auctionMu.RLock()
bidderNumber := auctionBidders[data.AuctionID][client.UserID]
auctionMu.RUnlock()
room := fmt.Sprintf("auction:%s", data.AuctionID)
// Broadcast new bid
h.BroadcastToRoom(room, mustJSON(map[string]interface{}{
"type": "auction:new_bid",
"payload": map[string]interface{}{
"bid": map[string]interface{}{
"amount": data.Amount,
"bidder_number": bidderNumber,
"created_at": time.Now().UTC().Format(time.RFC3339),
},
"auction": map[string]interface{}{
"current_price": data.Amount,
"minimum_bid": data.Amount + 50000000, // +50jt increment
},
},
}), nil)
// Confirm to bidder
client.SendJSON("auction:bid_accepted", map[string]interface{}{
"amount": data.Amount,
})
// TODO: Send outbid notification to previous high bidder
}
}
func AuctionLeave(h *hub.Hub) hub.MessageHandler {
return func(client *hub.Client, payload json.RawMessage) {
var data struct {
AuctionID string `json:"auction_id"`
}
if err := json.Unmarshal(payload, &data); err != nil {
return
}
room := fmt.Sprintf("auction:%s", data.AuctionID)
h.LeaveRoom(client, room)
h.BroadcastToRoom(room, mustJSON(map[string]interface{}{
"type": "auction:bidder_count",
"payload": map[string]interface{}{
"count": h.GetRoomClientCount(room),
},
}), nil)
}
}
Step 6: Utility Functions
// internal/handlers/utils.go
package handlers
import (
"encoding/json"
"crypto/rand"
"encoding/hex"
)
func mustJSON(v interface{}) []byte {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return data
}
func generateID() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
Step 7: Build dan Run
# Build
go build -o websocket-server cmd/server/main.go
# Run
./websocket-server
# Atau development
go run cmd/server/main.go
# Dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /websocket-server cmd/server/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /websocket-server /websocket-server
EXPOSE 8080
CMD ["/websocket-server"]
Performance Comparison
BENCHMARK: 10,000 Concurrent Connections
┌─────────────────────┬──────────────┬──────────────┬──────────────┐
│ Metric │ Laravel │ Node.js │ Golang │
│ │ Reverb │ Socket.io │ Gorilla │
├─────────────────────┼──────────────┼──────────────┼──────────────┤
│ Memory Usage │ ~500MB │ ~800MB │ ~200MB │
│ CPU Usage │ ~40% │ ~35% │ ~15% │
│ Msg/sec throughput │ ~50,000 │ ~80,000 │ ~200,000 │
│ Latency (p99) │ ~15ms │ ~10ms │ ~3ms │
│ Max Connections │ ~50,000 │ ~100,000 │ ~500,000 │
└─────────────────────┴──────────────┴──────────────┴──────────────┘
RECOMMENDATION:
├── Laravel Reverb: Best untuk Laravel apps, easy integration
├── Node.js Socket.io: Best untuk JS full-stack, good balance
└── Golang: Best untuk extreme scale, performance critical
Dengan tiga backend options ini, kamu bisa pilih sesuai kebutuhan dan expertise tim. Di bagian selanjutnya, kita akan implementasi frontend dengan React, Vue, dan Next.js.
Bagian 7: Implementasi Frontend — React, Vue, dan Next.js
Sekarang kita sudah punya backend yang solid. Saatnya connect frontend ke WebSocket server. Kita akan implementasi dengan tiga framework populer: React, Vue.js, dan Next.js.
REACT IMPLEMENTATION
Custom Hook: useWebSocket
// hooks/useWebSocket.js
import { useEffect, useRef, useState, useCallback } from 'react';
export function useWebSocket(url, options = {}) {
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState(null);
const socketRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const reconnectAttempts = useRef(0);
const {
token,
onOpen,
onClose,
onError,
reconnect = true,
maxReconnectAttempts = 5,
} = options;
const connect = useCallback(() => {
try {
const wsUrl = token ? `${url}?token=${token}` : url;
socketRef.current = new WebSocket(wsUrl);
socketRef.current.onopen = () => {
setIsConnected(true);
reconnectAttempts.current = 0;
onOpen?.();
};
socketRef.current.onclose = (event) => {
setIsConnected(false);
onClose?.(event);
// Auto reconnect
if (reconnect && reconnectAttempts.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttempts.current++;
connect();
}, delay);
}
};
socketRef.current.onerror = (error) => {
onError?.(error);
};
socketRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setLastMessage(data);
} catch {
setLastMessage(event.data);
}
};
} catch (error) {
console.error('WebSocket connection error:', error);
}
}, [url, token, onOpen, onClose, onError, reconnect, maxReconnectAttempts]);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (socketRef.current) {
socketRef.current.close();
}
};
}, [connect]);
const sendMessage = useCallback((type, payload) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type, payload }));
}
}, []);
const subscribe = useCallback((eventType, handler) => {
const listener = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === eventType) {
handler(data.payload);
}
} catch {
// Ignore parse errors
}
};
socketRef.current?.addEventListener('message', listener);
return () => socketRef.current?.removeEventListener('message', listener);
}, []);
return {
isConnected,
lastMessage,
sendMessage,
subscribe,
socket: socketRef.current,
};
}
Chat Component
// components/ChatWindow.jsx
import { useState, useEffect, useRef } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
export function ChatWindow({ conversationId, currentUser, agent }) {
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const [isAgentTyping, setIsAgentTyping] = useState(false);
const messagesEndRef = useRef(null);
const typingTimeoutRef = useRef(null);
const { isConnected, sendMessage, subscribe } = useWebSocket(
process.env.NEXT_PUBLIC_WS_URL,
{ token: currentUser.token }
);
// Join conversation on mount
useEffect(() => {
if (isConnected) {
sendMessage('chat:join', { conversation_id: conversationId });
}
return () => {
if (isConnected) {
sendMessage('chat:leave', { conversation_id: conversationId });
}
};
}, [isConnected, conversationId, sendMessage]);
// Subscribe to events
useEffect(() => {
const unsubMessage = subscribe('chat:new_message', (payload) => {
setMessages(prev => [...prev, payload]);
setIsAgentTyping(false);
});
const unsubTyping = subscribe('chat:user_typing', (payload) => {
if (payload.user_id !== currentUser.id) {
setIsAgentTyping(true);
}
});
const unsubStopTyping = subscribe('chat:user_stopped_typing', () => {
setIsAgentTyping(false);
});
return () => {
unsubMessage();
unsubTyping();
unsubStopTyping();
};
}, [subscribe, currentUser.id]);
// Auto scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = () => {
if (!inputValue.trim()) return;
sendMessage('chat:send_message', {
conversation_id: conversationId,
content: inputValue,
});
setInputValue('');
};
const handleTyping = () => {
sendMessage('chat:typing', {
conversation_id: conversationId,
is_typing: true,
});
// Stop typing after 2 seconds of no input
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
sendMessage('chat:typing', {
conversation_id: conversationId,
is_typing: false,
});
}, 2000);
};
return (
<div className="flex flex-col h-96 border rounded-lg">
{/* Header */}
<div className="p-4 border-b bg-gray-50">
<div className="flex items-center gap-3">
<img
src={agent.avatar}
alt={agent.name}
className="w-10 h-10 rounded-full"
/>
<div>
<h3 className="font-semibold">{agent.name}</h3>
<span className={`text-sm ${isConnected ? 'text-green-500' : 'text-gray-400'}`}>
{isConnected ? '🟢 Online' : '⚫ Offline'}
</span>
</div>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.sender.id === currentUser.id ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-xs px-4 py-2 rounded-lg ${
msg.sender.id === currentUser.id
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-800'
}`}
>
<p>{msg.content}</p>
<span className="text-xs opacity-70">
{new Date(msg.created_at).toLocaleTimeString()}
</span>
</div>
</div>
))}
{isAgentTyping && (
<div className="flex justify-start">
<div className="bg-gray-200 px-4 py-2 rounded-lg">
<span className="text-gray-500">
{agent.name} sedang mengetik...
</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 border-t">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
handleTyping();
}}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
placeholder="Ketik pesan..."
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleSend}
disabled={!isConnected}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
Kirim
</button>
</div>
</div>
</div>
);
}
Property Card dengan Live Viewers
// components/PropertyCard.jsx
import { useState, useEffect } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
export function PropertyCard({ property, userToken }) {
const [viewerCount, setViewerCount] = useState(0);
const [currentStatus, setCurrentStatus] = useState(property.status);
const [currentPrice, setCurrentPrice] = useState(property.price);
const [priceDropAlert, setPriceDropAlert] = useState(null);
const { isConnected, sendMessage, subscribe } = useWebSocket(
process.env.NEXT_PUBLIC_WS_URL,
{ token: userToken }
);
// Join property room on mount
useEffect(() => {
if (isConnected) {
sendMessage('property:view', { property_id: property.id });
}
return () => {
if (isConnected) {
sendMessage('property:leave', { property_id: property.id });
}
};
}, [isConnected, property.id, sendMessage]);
// Subscribe to property events
useEffect(() => {
const unsubViewers = subscribe('property:viewer_count', (payload) => {
if (payload.propertyId === property.id) {
setViewerCount(payload.count);
}
});
const unsubStatus = subscribe('property:status_changed', (payload) => {
if (payload.property_id === property.id) {
setCurrentStatus(payload.new_status);
}
});
const unsubPrice = subscribe('property:price_updated', (payload) => {
if (payload.property_id === property.id) {
setCurrentPrice(payload.new_price);
if (payload.is_price_drop) {
setPriceDropAlert({
oldPrice: payload.old_price,
newPrice: payload.new_price,
percentage: payload.percentage_change,
});
// Auto dismiss after 5 seconds
setTimeout(() => setPriceDropAlert(null), 5000);
}
}
});
return () => {
unsubViewers();
unsubStatus();
unsubPrice();
};
}, [subscribe, property.id]);
const formatPrice = (price) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(price);
};
const getStatusBadge = () => {
const statusStyles = {
available: 'bg-green-100 text-green-800',
under_offer: 'bg-yellow-100 text-yellow-800',
sold: 'bg-red-100 text-red-800',
};
const statusLabels = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
return (
<span className={`px-2 py-1 rounded text-sm ${statusStyles[currentStatus]}`}>
{statusLabels[currentStatus]}
</span>
);
};
return (
<div className="border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow">
{/* Image */}
<div className="relative">
<img
src={property.image}
alt={property.title}
className="w-full h-48 object-cover"
/>
{/* Live Viewers Badge */}
{viewerCount > 1 && (
<div className="absolute top-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-sm">
👀 {viewerCount} orang sedang melihat
</div>
)}
{/* Status Badge */}
<div className="absolute top-2 right-2">
{getStatusBadge()}
</div>
{/* Price Drop Alert */}
{priceDropAlert && (
<div className="absolute bottom-0 left-0 right-0 bg-green-500 text-white p-2 text-center animate-pulse">
🔥 PRICE DROP! {priceDropAlert.percentage}% OFF
</div>
)}
</div>
{/* Content */}
<div className="p-4">
<h3 className="font-semibold text-lg mb-1">{property.title}</h3>
<p className="text-gray-500 text-sm mb-2">{property.location}</p>
<div className="flex items-baseline gap-2 mb-3">
<span className="text-xl font-bold text-blue-600">
{formatPrice(currentPrice)}
</span>
{priceDropAlert && (
<span className="text-sm text-gray-400 line-through">
{formatPrice(priceDropAlert.oldPrice)}
</span>
)}
</div>
<div className="flex gap-4 text-sm text-gray-600 mb-4">
<span>🛏️ {property.bedrooms} BR</span>
<span>🚿 {property.bathrooms} BA</span>
<span>📐 {property.size} m²</span>
</div>
<button
className={`w-full py-2 rounded-lg ${
currentStatus === 'available'
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
disabled={currentStatus !== 'available'}
>
{currentStatus === 'available' ? 'Hubungi Agen' : 'Tidak Tersedia'}
</button>
</div>
</div>
);
}
VUE.JS IMPLEMENTATION
Composable: useSocket
// composables/useSocket.js
import { ref, onMounted, onUnmounted } from 'vue';
export function useSocket(url, options = {}) {
const isConnected = ref(false);
const lastMessage = ref(null);
let socket = null;
let reconnectTimeout = null;
let reconnectAttempts = 0;
const { token, maxReconnectAttempts = 5 } = options;
const connect = () => {
const wsUrl = token ? `${url}?token=${token}` : url;
socket = new WebSocket(wsUrl);
socket.onopen = () => {
isConnected.value = true;
reconnectAttempts = 0;
};
socket.onclose = () => {
isConnected.value = false;
if (reconnectAttempts < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectTimeout = setTimeout(() => {
reconnectAttempts++;
connect();
}, delay);
}
};
socket.onmessage = (event) => {
try {
lastMessage.value = JSON.parse(event.data);
} catch {
lastMessage.value = event.data;
}
};
};
const sendMessage = (type, payload) => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type, payload }));
}
};
const on = (eventType, handler) => {
const listener = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === eventType) {
handler(data.payload);
}
} catch {
// Ignore
}
};
socket?.addEventListener('message', listener);
return () => socket?.removeEventListener('message', listener);
};
onMounted(() => {
connect();
});
onUnmounted(() => {
clearTimeout(reconnectTimeout);
socket?.close();
});
return {
isConnected,
lastMessage,
sendMessage,
on,
};
}
Chat Component
<!-- components/ChatWindow.vue -->
<template>
<div class="flex flex-col h-96 border rounded-lg">
<!-- Header -->
<div class="p-4 border-b bg-gray-50">
<div class="flex items-center gap-3">
<img :src="agent.avatar" :alt="agent.name" class="w-10 h-10 rounded-full" />
<div>
<h3 class="font-semibold">{{ agent.name }}</h3>
<span :class="isConnected ? 'text-green-500' : 'text-gray-400'" class="text-sm">
{{ isConnected ? '🟢 Online' : '⚫ Offline' }}
</span>
</div>
</div>
</div>
<!-- Messages -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-4 space-y-3">
<div
v-for="msg in messages"
:key="msg.id"
:class="msg.sender.id === currentUser.id ? 'justify-end' : 'justify-start'"
class="flex"
>
<div
:class="msg.sender.id === currentUser.id
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-800'"
class="max-w-xs px-4 py-2 rounded-lg"
>
<p>{{ msg.content }}</p>
<span class="text-xs opacity-70">
{{ formatTime(msg.created_at) }}
</span>
</div>
</div>
<div v-if="isAgentTyping" class="flex justify-start">
<div class="bg-gray-200 px-4 py-2 rounded-lg">
<span class="text-gray-500">{{ agent.name }} sedang mengetik...</span>
</div>
</div>
</div>
<!-- Input -->
<div class="p-4 border-t">
<div class="flex gap-2">
<input
v-model="inputValue"
@input="handleTyping"
@keypress.enter="handleSend"
type="text"
placeholder="Ketik pesan..."
class="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
@click="handleSend"
:disabled="!isConnected"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
Kirim
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { useSocket } from '../composables/useSocket';
const props = defineProps({
conversationId: String,
currentUser: Object,
agent: Object,
});
const messages = ref([]);
const inputValue = ref('');
const isAgentTyping = ref(false);
const messagesContainer = ref(null);
let typingTimeout = null;
const { isConnected, sendMessage, on } = useSocket(
import.meta.env.VITE_WS_URL,
{ token: props.currentUser.token }
);
// Join conversation
onMounted(() => {
if (isConnected.value) {
sendMessage('chat:join', { conversation_id: props.conversationId });
}
});
// Watch connection status
watch(isConnected, (connected) => {
if (connected) {
sendMessage('chat:join', { conversation_id: props.conversationId });
}
});
// Subscribe to events
onMounted(() => {
on('chat:new_message', (payload) => {
messages.value.push(payload);
isAgentTyping.value = false;
scrollToBottom();
});
on('chat:user_typing', (payload) => {
if (payload.user_id !== props.currentUser.id) {
isAgentTyping.value = true;
}
});
on('chat:user_stopped_typing', () => {
isAgentTyping.value = false;
});
});
onUnmounted(() => {
sendMessage('chat:leave', { conversation_id: props.conversationId });
});
const handleSend = () => {
if (!inputValue.value.trim()) return;
sendMessage('chat:send_message', {
conversation_id: props.conversationId,
content: inputValue.value,
});
inputValue.value = '';
};
const handleTyping = () => {
sendMessage('chat:typing', {
conversation_id: props.conversationId,
is_typing: true,
});
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
sendMessage('chat:typing', {
conversation_id: props.conversationId,
is_typing: false,
});
}, 2000);
};
const scrollToBottom = async () => {
await nextTick();
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
};
const formatTime = (dateString) => {
return new Date(dateString).toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
});
};
</script>
Auction Component
<!-- components/AuctionPanel.vue -->
<template>
<div class="border rounded-lg p-6 bg-white shadow-lg">
<!-- Current Bid -->
<div class="text-center mb-6">
<p class="text-gray-500 mb-1">Current Bid</p>
<p class="text-4xl font-bold text-blue-600">{{ formatPrice(currentBid) }}</p>
<p class="text-sm text-gray-500">by Bidder #{{ currentBidder }}</p>
</div>
<!-- Timer -->
<div class="text-center mb-6">
<p class="text-gray-500 mb-1">Time Remaining</p>
<p class="text-3xl font-mono font-bold" :class="timeRemaining < 60 ? 'text-red-500' : ''">
{{ formatTime(timeRemaining) }}
</p>
</div>
<!-- Outbid Alert -->
<div v-if="isOutbid" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
⚠️ You've been outbid! Minimum to win: {{ formatPrice(minimumToWin) }}
</div>
<!-- Bid Form -->
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-600 mb-1">Your Bid (minimum: {{ formatPrice(minimumBid) }})</label>
<input
v-model.number="bidAmount"
type="number"
:min="minimumBid"
:step="bidIncrement"
class="w-full px-4 py-3 border rounded-lg text-lg"
/>
</div>
<button
@click="placeBid"
:disabled="!canBid"
class="w-full py-3 bg-green-500 text-white rounded-lg font-semibold hover:bg-green-600 disabled:opacity-50"
>
🔨 Place Bid
</button>
</div>
<!-- Active Bidders -->
<div class="mt-6 pt-4 border-t text-center">
<span class="text-gray-500">👥 {{ activeBidders }} Active Bidders</span>
</div>
<!-- Bid History -->
<div class="mt-6">
<h4 class="font-semibold mb-2">Recent Bids</h4>
<div class="space-y-2 max-h-40 overflow-y-auto">
<div
v-for="bid in recentBids"
:key="bid.id"
class="flex justify-between text-sm py-1 border-b"
>
<span>Bidder #{{ bid.bidder_number }}</span>
<span class="font-semibold">{{ formatPrice(bid.amount) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useSocket } from '../composables/useSocket';
const props = defineProps({
auctionId: String,
userToken: String,
});
const currentBid = ref(0);
const currentBidder = ref(0);
const minimumBid = ref(0);
const bidIncrement = ref(50000000);
const timeRemaining = ref(0);
const activeBidders = ref(0);
const recentBids = ref([]);
const bidAmount = ref(0);
const isOutbid = ref(false);
const minimumToWin = ref(0);
const myBidderNumber = ref(0);
let timerInterval = null;
const { isConnected, sendMessage, on } = useSocket(
import.meta.env.VITE_WS_URL,
{ token: props.userToken }
);
const canBid = computed(() => {
return isConnected.value && bidAmount.value >= minimumBid.value && timeRemaining.value > 0;
});
onMounted(() => {
sendMessage('auction:join', { auction_id: props.auctionId });
// Subscribe to auction events
on('auction:state', (payload) => {
currentBid.value = payload.auction.currentPrice;
minimumBid.value = payload.auction.minimumBid;
bidIncrement.value = payload.auction.bidIncrement;
bidAmount.value = payload.auction.minimumBid;
myBidderNumber.value = payload.yourBidderNumber;
activeBidders.value = payload.activeBidders;
recentBids.value = payload.recentBids;
// Calculate time remaining
const endsAt = new Date(payload.auction.endsAt);
timeRemaining.value = Math.max(0, Math.floor((endsAt - Date.now()) / 1000));
});
on('auction:new_bid', (payload) => {
currentBid.value = payload.auction.currentPrice;
currentBidder.value = payload.bid.bidder_number;
minimumBid.value = payload.auction.minimumBid;
bidAmount.value = payload.auction.minimumBid;
recentBids.value.unshift(payload.bid);
recentBids.value = recentBids.value.slice(0, 10);
// Check if we were outbid
if (payload.bid.bidder_number !== myBidderNumber.value) {
// We might have been outbid
}
});
on('auction:outbid', (payload) => {
isOutbid.value = true;
minimumToWin.value = payload.minimumToWin;
setTimeout(() => {
isOutbid.value = false;
}, 10000);
});
on('auction:bidder_count', (payload) => {
activeBidders.value = payload.count;
});
on('auction:timer_sync', (payload) => {
const endsAt = new Date(payload.endsAt);
timeRemaining.value = Math.max(0, Math.floor((endsAt - Date.now()) / 1000));
});
// Start countdown timer
timerInterval = setInterval(() => {
if (timeRemaining.value > 0) {
timeRemaining.value--;
}
}, 1000);
});
onUnmounted(() => {
sendMessage('auction:leave', { auction_id: props.auctionId });
clearInterval(timerInterval);
});
const placeBid = () => {
if (!canBid.value) return;
sendMessage('auction:bid', {
auction_id: props.auctionId,
amount: bidAmount.value,
});
isOutbid.value = false;
};
const formatPrice = (price) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(price);
};
const formatTime = (seconds) => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
</script>
NEXT.JS IMPLEMENTATION
WebSocket Context Provider
// context/SocketContext.js
'use client';
import { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
const SocketContext = createContext(null);
export function SocketProvider({ children, token }) {
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef(null);
const listenersRef = useRef(new Map());
useEffect(() => {
// Only run on client
if (typeof window === 'undefined') return;
const wsUrl = `${process.env.NEXT_PUBLIC_WS_URL}?token=${token}`;
socketRef.current = new WebSocket(wsUrl);
socketRef.current.onopen = () => {
setIsConnected(true);
};
socketRef.current.onclose = () => {
setIsConnected(false);
};
socketRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const handlers = listenersRef.current.get(data.type);
if (handlers) {
handlers.forEach(handler => handler(data.payload));
}
} catch (error) {
console.error('Message parse error:', error);
}
};
return () => {
socketRef.current?.close();
};
}, [token]);
const sendMessage = useCallback((type, payload) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type, payload }));
}
}, []);
const subscribe = useCallback((eventType, handler) => {
if (!listenersRef.current.has(eventType)) {
listenersRef.current.set(eventType, new Set());
}
listenersRef.current.get(eventType).add(handler);
return () => {
listenersRef.current.get(eventType)?.delete(handler);
};
}, []);
return (
<SocketContext.Provider value={{ isConnected, sendMessage, subscribe }}>
{children}
</SocketContext.Provider>
);
}
export function useSocket() {
const context = useContext(SocketContext);
if (!context) {
throw new Error('useSocket must be used within SocketProvider');
}
return context;
}
Layout dengan Provider
// app/layout.js
import { SocketProvider } from '@/context/SocketContext';
import { getServerSession } from 'next-auth';
export default async function RootLayout({ children }) {
const session = await getServerSession();
return (
<html lang="id">
<body>
{session ? (
<SocketProvider token={session.accessToken}>
{children}
</SocketProvider>
) : (
children
)}
</body>
</html>
);
}
Property Page dengan Real-time
// app/property/[id]/page.js
'use client';
import { useEffect, useState } from 'react';
import { useSocket } from '@/context/SocketContext';
import { ChatWindow } from '@/components/ChatWindow';
export default function PropertyPage({ params }) {
const { id } = params;
const { isConnected, sendMessage, subscribe } = useSocket();
const [property, setProperty] = useState(null);
const [viewerCount, setViewerCount] = useState(0);
const [showChat, setShowChat] = useState(false);
// Fetch property data
useEffect(() => {
fetch(`/api/properties/${id}`)
.then(res => res.json())
.then(setProperty);
}, [id]);
// WebSocket subscriptions
useEffect(() => {
if (!isConnected) return;
// Join property room
sendMessage('property:view', { property_id: id });
// Subscribe to updates
const unsubViewers = subscribe('property:viewer_count', (payload) => {
if (payload.propertyId === id) {
setViewerCount(payload.count);
}
});
const unsubStatus = subscribe('property:status_changed', (payload) => {
if (payload.property_id === id) {
setProperty(prev => ({ ...prev, status: payload.new_status }));
}
});
const unsubPrice = subscribe('property:price_updated', (payload) => {
if (payload.property_id === id) {
setProperty(prev => ({ ...prev, price: payload.new_price }));
}
});
return () => {
sendMessage('property:leave', { property_id: id });
unsubViewers();
unsubStatus();
unsubPrice();
};
}, [isConnected, id, sendMessage, subscribe]);
if (!property) return <div>Loading...</div>;
return (
<div className="max-w-6xl mx-auto p-6">
{/* Live Viewers Badge */}
{viewerCount > 1 && (
<div className="bg-orange-100 text-orange-800 px-4 py-2 rounded-lg mb-4 inline-block">
👀 {viewerCount} orang sedang melihat properti ini
</div>
)}
{/* Property Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<img src={property.image} alt={property.title} className="w-full rounded-lg" />
</div>
<div>
<h1 className="text-3xl font-bold mb-2">{property.title}</h1>
<p className="text-gray-500 mb-4">{property.location}</p>
<p className="text-4xl font-bold text-blue-600 mb-6">
{new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
}).format(property.price)}
</p>
<button
onClick={() => setShowChat(true)}
className="w-full py-3 bg-green-500 text-white rounded-lg font-semibold hover:bg-green-600"
>
💬 Chat dengan Agen
</button>
</div>
</div>
{/* Chat Modal */}
{showChat && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg w-full max-w-md">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-semibold">Chat dengan Agen</h3>
<button onClick={() => setShowChat(false)}>✕</button>
</div>
<ChatWindow
conversationId={property.conversation_id}
agent={property.agent}
/>
</div>
</div>
)}
</div>
);
}
Dengan implementasi frontend ini, aplikasi sudah fully real-time! Di bagian selanjutnya, kita bahas best practices untuk production.
Bagian 8: Best Practices dan Production Tips
Membangun WebSocket untuk development adalah satu hal. Menjalankannya di production dengan ribuan concurrent users adalah tantangan yang berbeda. Berikut best practices yang wajib diperhatikan.
1. SECURITY
Authentication sebelum Connection
// ❌ SALAH: Authenticate setelah connect
socket.on('connection', (socket) => {
socket.on('authenticate', (token) => {
// Terlambat! Connection sudah terbuka
});
});
// ✅ BENAR: Authenticate saat handshake
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication required'));
}
try {
const user = await verifyToken(token);
socket.user = user;
next();
} catch (error) {
next(new Error('Invalid token'));
}
});
Validate Semua Incoming Messages
// ✅ BENAR: Validate sebelum process
socket.on('chat:send_message', async (data) => {
// Validate structure
const schema = Joi.object({
conversation_id: Joi.string().required(),
content: Joi.string().max(5000).required(),
});
const { error, value } = schema.validate(data);
if (error) {
return socket.emit('error', { message: 'Invalid data' });
}
// Validate authorization
const canSend = await userCanSendTo(socket.user.id, value.conversation_id);
if (!canSend) {
return socket.emit('error', { message: 'Not authorized' });
}
// Process message
// ...
});
Rate Limiting
// Rate limiter per user
const rateLimits = new Map();
function checkRateLimit(userId, action, limit = 10, windowMs = 1000) {
const key = `${userId}:${action}`;
const now = Date.now();
if (!rateLimits.has(key)) {
rateLimits.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
const record = rateLimits.get(key);
if (now > record.resetAt) {
record.count = 1;
record.resetAt = now + windowMs;
return true;
}
if (record.count >= limit) {
return false; // Rate limited
}
record.count++;
return true;
}
// Usage
socket.on('chat:send_message', (data) => {
if (!checkRateLimit(socket.user.id, 'message', 5, 1000)) {
return socket.emit('error', { message: 'Too many messages' });
}
// Process...
});
Origin Validation
const io = new Server(httpServer, {
cors: {
origin: (origin, callback) => {
const allowedOrigins = [
'<https://rumahku.com>',
'<https://www.rumahku.com>',
'<https://app.rumahku.com>',
];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
},
});
2. SCALING
Horizontal Scaling dengan Redis
// Tanpa Redis: Hanya 1 server bisa handle
// Dengan Redis: Multiple servers, shared state
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
ARCHITECTURE:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │ │ Client │ │ Client │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────┐
│ LOAD BALANCER │
│ (Sticky Sessions Required) │
└──────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ WS Server │ │ WS Server │ │ WS Server │
│ #1 │ │ #2 │ │ #3 │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────────┼───────────────────┘
│
▼
┌─────────────┐
│ REDIS │
│ Pub/Sub │
└─────────────┘
Sticky Sessions untuk Load Balancer
# Nginx configuration
upstream websocket {
ip_hash; # Sticky sessions berdasarkan IP
server ws1.rumahku.com:8080;
server ws2.rumahku.com:8080;
server ws3.rumahku.com:8080;
}
server {
listen 443 ssl;
server_name ws.rumahku.com;
location / {
proxy_pass <http://websocket>;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400; # 24 hours
}
}
Connection Limits
// Limit connections per user
const userConnections = new Map();
const MAX_CONNECTIONS_PER_USER = 5;
io.use((socket, next) => {
const userId = socket.user.id;
const count = userConnections.get(userId) || 0;
if (count >= MAX_CONNECTIONS_PER_USER) {
return next(new Error('Too many connections'));
}
userConnections.set(userId, count + 1);
socket.on('disconnect', () => {
const current = userConnections.get(userId);
if (current <= 1) {
userConnections.delete(userId);
} else {
userConnections.set(userId, current - 1);
}
});
next();
});
3. ERROR HANDLING
Automatic Reconnection (Client)
// Custom reconnection dengan exponential backoff
class WebSocketClient {
constructor(url, options = {}) {
this.url = url;
this.maxRetries = options.maxRetries || 10;
this.retryCount = 0;
this.listeners = new Map();
this.messageQueue = [];
this.connect();
}
connect() {
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
this.retryCount = 0;
// Flush queued messages
while (this.messageQueue.length > 0) {
const msg = this.messageQueue.shift();
this.socket.send(msg);
}
};
this.socket.onclose = (event) => {
if (!event.wasClean && this.retryCount < this.maxRetries) {
const delay = Math.min(1000 * Math.pow(2, this.retryCount), 30000);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(() => {
this.retryCount++;
this.connect();
}, delay);
}
};
this.socket.onmessage = (event) => {
const data = JSON.parse(event.data);
const handlers = this.listeners.get(data.type);
handlers?.forEach(handler => handler(data.payload));
};
}
send(type, payload) {
const message = JSON.stringify({ type, payload });
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(message);
} else {
// Queue message untuk kirim setelah reconnect
this.messageQueue.push(message);
}
}
}
Graceful Degradation
// React component dengan fallback
function PropertyCard({ property }) {
const { isConnected } = useSocket();
const [viewerCount, setViewerCount] = useState(null);
// Fallback ke polling jika WebSocket disconnected
useEffect(() => {
if (!isConnected) {
const interval = setInterval(async () => {
const res = await fetch(`/api/properties/${property.id}/viewers`);
const data = await res.json();
setViewerCount(data.count);
}, 10000); // Poll setiap 10 detik
return () => clearInterval(interval);
}
}, [isConnected, property.id]);
return (
<div>
{viewerCount !== null && (
<span>
👀 {viewerCount} viewing
{!isConnected && ' (delayed)'}
</span>
)}
</div>
);
}
4. PERFORMANCE
Message Batching
// Server-side batching
class MessageBatcher {
constructor(io, options = {}) {
this.io = io;
this.batchInterval = options.interval || 100; // ms
this.batches = new Map(); // room -> messages[]
setInterval(() => this.flush(), this.batchInterval);
}
add(room, message) {
if (!this.batches.has(room)) {
this.batches.set(room, []);
}
this.batches.get(room).push(message);
}
flush() {
for (const [room, messages] of this.batches) {
if (messages.length > 0) {
this.io.to(room).emit('batch', messages);
this.batches.set(room, []);
}
}
}
}
// Usage
const batcher = new MessageBatcher(io);
socket.on('auction:bid', (data) => {
// Instead of immediate broadcast
batcher.add(`auction:${data.auctionId}`, {
type: 'bid',
data: data,
});
});
Compression
const io = new Server(httpServer, {
perMessageDeflate: {
threshold: 1024, // Compress messages > 1KB
zlibDeflateOptions: {
chunkSize: 16 * 1024,
},
zlibInflateOptions: {
chunkSize: 16 * 1024,
},
},
});
Heartbeat/Ping-Pong
// Server
const io = new Server(httpServer, {
pingTimeout: 60000, // Close connection if no pong in 60s
pingInterval: 25000, // Send ping every 25s
});
// Client (native WebSocket)
let pingInterval;
socket.onopen = () => {
pingInterval = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'ping' }));
}
}, 25000);
};
socket.onclose = () => {
clearInterval(pingInterval);
};
5. MONITORING
Connection Metrics
// Prometheus-style metrics
const metrics = {
connectionsTotal: 0,
connectionsActive: 0,
messagesReceived: 0,
messagesSent: 0,
errors: 0,
};
io.on('connection', (socket) => {
metrics.connectionsTotal++;
metrics.connectionsActive++;
socket.on('disconnect', () => {
metrics.connectionsActive--;
});
});
// Expose metrics endpoint
app.get('/metrics', (req, res) => {
res.json({
...metrics,
uptime: process.uptime(),
memory: process.memoryUsage(),
});
});
Logging
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'websocket.log' }),
],
});
io.on('connection', (socket) => {
logger.info('Client connected', {
userId: socket.user.id,
ip: socket.handshake.address,
});
socket.onAny((event, ...args) => {
logger.debug('Event received', {
userId: socket.user.id,
event,
timestamp: new Date().toISOString(),
});
});
socket.on('disconnect', (reason) => {
logger.info('Client disconnected', {
userId: socket.user.id,
reason,
});
});
});
Alerting
// Alert jika connections drop drastically
let previousConnectionCount = 0;
setInterval(() => {
const currentCount = io.engine.clientsCount;
const dropPercentage = ((previousConnectionCount - currentCount) / previousConnectionCount) * 100;
if (dropPercentage > 20 && previousConnectionCount > 100) {
// Send alert
sendSlackAlert({
text: `⚠️ WebSocket connections dropped ${dropPercentage.toFixed(1)}%`,
details: {
previous: previousConnectionCount,
current: currentCount,
},
});
}
previousConnectionCount = currentCount;
}, 30000); // Check every 30 seconds
6. COMMON PITFALLS
┌─────────────────────────────────────────────────────────────────────┐
│ COMMON WEBSOCKET PITFALLS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ PITFALL #1: Memory Leaks dari Event Listeners │
│ ───────────────────────────────────────────── │
│ // SALAH: Listener tidak di-cleanup │
│ useEffect(() => { │
│ socket.on('message', handleMessage); │
│ // Missing cleanup! │
│ }, []); │
│ │
│ // BENAR: Cleanup on unmount │
│ useEffect(() => { │
│ socket.on('message', handleMessage); │
│ return () => socket.off('message', handleMessage); │
│ }, []); │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ PITFALL #2: Tidak Handle Reconnection │
│ ───────────────────────────────────────────── │
│ Setelah reconnect, client harus re-join rooms! │
│ │
│ socket.on('connect', () => { │
│ // Re-join semua rooms setelah reconnect │
│ currentRooms.forEach(room => { │
│ socket.emit('join', room); │
│ }); │
│ }); │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ PITFALL #3: Over-broadcasting │
│ ───────────────────────────────────────────── │
│ // SALAH: Broadcast ke semua untuk setiap update │
│ io.emit('update', data); // Semua 100K users dapat! │
│ │
│ // BENAR: Broadcast ke relevant rooms saja │
│ io.to(`property:${propertyId}`).emit('update', data); │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ PITFALL #4: Missing Authentication │
│ ───────────────────────────────────────────── │
│ Selalu authenticate sebelum upgrade ke WebSocket! │
│ Jangan percaya client-sent user ID. │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ PITFALL #5: Mobile Considerations │
│ ───────────────────────────────────────────── │
│ - Battery: Reduce ping frequency untuk mobile │
│ - Data: Compress messages, batch updates │
│ - Connectivity: Handle frequent disconnects gracefully │
│ - Background: iOS/Android may kill WS in background │
│ │
└─────────────────────────────────────────────────────────────────────┘
Production Checklist
┌─────────────────────────────────────────────────────────────────────┐
│ PRODUCTION CHECKLIST │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ □ SECURITY │
│ ├── □ Authentication pada handshake │
│ ├── □ Authorization per room/channel │
│ ├── □ Input validation semua messages │
│ ├── □ Rate limiting per user/action │
│ ├── □ Origin validation (CORS) │
│ └── □ TLS/WSS untuk production │
│ │
│ □ SCALING │
│ ├── □ Redis adapter untuk horizontal scaling │
│ ├── □ Sticky sessions di load balancer │
│ ├── □ Connection limits per user │
│ └── □ Graceful shutdown handling │
│ │
│ □ RELIABILITY │
│ ├── □ Auto reconnection dengan backoff │
│ ├── □ Message queue untuk offline │
│ ├── □ Heartbeat/ping-pong │
│ └── □ Graceful degradation (fallback ke polling) │
│ │
│ □ PERFORMANCE │
│ ├── □ Message compression │
│ ├── □ Batching untuk high-frequency updates │
│ ├── □ Efficient room/channel structure │
│ └── □ Memory leak prevention │
│ │
│ □ MONITORING │
│ ├── □ Connection metrics │
│ ├── □ Message throughput │
│ ├── □ Error rates dan alerting │
│ ├── □ Latency monitoring │
│ └── □ Logging untuk debugging │
│ │
│ □ DEPLOYMENT │
│ ├── □ Health check endpoint │
│ ├── □ Zero-downtime deployment strategy │
│ ├── □ Rollback plan │
│ └── □ Load testing sebelum launch │
│ │
└─────────────────────────────────────────────────────────────────────┘
Dengan best practices ini, WebSocket server kamu siap untuk handle production traffic. Di bagian terakhir, kita akan wrap up dan memberikan rekomendasi untuk belajar lebih lanjut.
Bagian 9: Kesimpulan dan Langkah Selanjutnya
Kita sudah membahas WebSocket dari konsep dasar hingga production implementation. Mari wrap up dengan ringkasan dan langkah selanjutnya untuk memperdalam skill kamu.
Ringkasan: Apa yang Sudah Kita Pelajari
WEBSOCKET JOURNEY:
📖 KONSEP (Bagian 1-3)
├── Problem: Platform properti tanpa real-time = bad UX
├── WebSocket = persistent, bidirectional connection
├── HTTP vs WebSocket vs SSE vs Polling
├── 5 Fitur real-time untuk platform properti:
│ ├── 💬 Live Chat dengan agen
│ ├── 🏠 Property status updates
│ ├── 🔨 Live auction/bidding
│ ├── 👀 Presence (live viewers)
│ └── 🔔 Instant notifications
└── Kapan gunakan dan kapan hindari WebSocket
💻 BACKEND (Bagian 4-6)
├── Laravel Reverb
│ ├── First-party, self-hosted
│ ├── Events, channels, broadcasting
│ └── Best untuk Laravel ecosystem
│
├── Node.js Socket.io
│ ├── Auto-fallback, rooms, namespaces
│ ├── Universal, cross-platform
│ └── Best untuk JS full-stack
│
└── Golang Gorilla
├── Hub pattern, goroutines
├── Maximum performance
└── Best untuk extreme scale
🎨 FRONTEND (Bagian 7)
├── React: Custom hooks (useWebSocket)
├── Vue.js: Composables (useSocket)
├── Next.js: Context provider
└── Shared patterns: reconnection, state management
🚀 PRODUCTION (Bagian 8)
├── Security: Auth, validation, rate limiting
├── Scaling: Redis adapter, sticky sessions
├── Error handling: Reconnection, graceful degradation
├── Performance: Batching, compression, heartbeat
└── Monitoring: Metrics, logging, alerting
Key Takeaways
🔑 INGAT INI:
1. WEBSOCKET BUKAN UNTUK SEGALANYA
├── Gunakan untuk: chat, live updates, auctions, presence
└── Jangan gunakan untuk: file uploads, SEO content, one-time fetches
2. AUTHENTICATION IS CRITICAL
├── Authenticate sebelum upgrade ke WebSocket
├── Validate setiap incoming message
└── Never trust client-sent data
3. DESIGN FOR FAILURE
├── Implement reconnection dengan backoff
├── Queue messages saat offline
└── Graceful degradation ke polling
4. SCALE HORIZONTALLY
├── Use Redis pub/sub
├── Configure sticky sessions
└── Limit connections per user
5. MONITOR EVERYTHING
├── Connection counts
├── Message throughput
└── Error rates dan alerting
Framework Recommendation
┌─────────────────────────────────────────────────────────────────────┐
│ PILIH FRAMEWORK BERDASARKAN: │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 🟦 LARAVEL REVERB jika: │
│ ├── Sudah menggunakan Laravel sebagai backend │
│ ├── Tim familiar dengan PHP ecosystem │
│ ├── Butuh integration yang seamless dengan Laravel │
│ └── Scale requirement: < 50,000 concurrent connections │
│ │
│ 🟨 NODE.JS SOCKET.IO jika: │
│ ├── Full-stack JavaScript (React/Vue/Next + Node) │
│ ├── Butuh flexibility dan banyak packages │
│ ├── Tim comfortable dengan async JavaScript │
│ └── Scale requirement: < 100,000 concurrent connections │
│ │
│ 🟩 GOLANG GORILLA jika: │
│ ├── Performance adalah prioritas #1 │
│ ├── Butuh handle > 100,000 concurrent connections │
│ ├── Tim experienced dengan Go │
│ └── Resource efficiency sangat penting │
│ │
│ 💡 HYBRID APPROACH: │
│ ├── Main API: Laravel/Node │
│ └── WebSocket Server: Dedicated service (any language) │
│ → Allows independent scaling │
│ │
└─────────────────────────────────────────────────────────────────────┘
Business Impact
SEBELUM WEBSOCKET: SETELAH WEBSOCKET:
───────────────────── ─────────────────────
Chat response: 5-15 menit Chat response: < 1 menit
Inquiry conversion: 3% Inquiry conversion: 12%
User frustration: Tinggi User frustration: Minimal
Stale data complaints: Banyak Stale data complaints: Rare
Auction fairness: Questionable Auction fairness: Transparent
Infrastructure cost: $$$ Infrastructure cost: $$
ROI:
├── 4x conversion rate improvement
├── 50% reduction in support tickets
├── Higher user satisfaction scores
└── Competitive advantage
Belajar Lebih Lanjut di BuildWithAngga
Artikel ini memberikan fondasi yang solid, tapi untuk benar-benar menguasai WebSocket dan real-time applications, butuh practice yang konsisten dengan guidance yang tepat.
Kelas Backend Development
📚 LARAVEL TRACK
├── Laravel Web Development Fundamentals
│ └── Foundation untuk memahami Laravel ecosystem
│
├── Laravel API Development
│ └── RESTful API, authentication, best practices
│
├── Laravel Broadcasting & Real-time
│ └── Events, channels, Reverb, Pusher
│
└── Build Complete SaaS dengan Laravel
└── Full project dengan real-time features
📚 NODE.JS TRACK
├── Node.js Backend Fundamentals
│ └── Express, async patterns, middleware
│
├── Node.js dengan Socket.io
│ └── Real-time apps dari scratch
│
├── Node.js Microservices
│ └── Scalable architecture patterns
│
└── Build Real-time Chat Application
└── Complete project: chat + notifications
📚 GOLANG TRACK
├── Golang Fundamentals
│ └── Syntax, concurrency, packages
│
├── Golang Web Development
│ └── HTTP servers, routing, middleware
│
├── Golang WebSocket & Concurrency
│ └── Goroutines, channels, WebSocket
│
└── Build High-Performance API dengan Go
└── Production-ready Go backend
Kelas Frontend Development
📚 FRONTEND REAL-TIME
├── React.js Modern Development
│ └── Hooks, state management, best practices
│
├── Vue.js 3 Complete Guide
│ └── Composition API, Pinia, real-time
│
├── Next.js Full-stack Development
│ └── SSR, API routes, WebSocket integration
│
└── Build Real-time Dashboard
└── Charts, live updates, notifications
Kelas Full-Stack Projects
📚 PROJECT-BASED LEARNING
├── Build Property Listing Platform
│ └── Complete platform seperti yang dibahas di artikel
│
├── Build E-commerce dengan Real-time
│ └── Live inventory, chat, notifications
│
├── Build Auction Platform
│ └── Real-time bidding, countdown, presence
│
├── Build Collaborative Tool
│ └── Real-time editing, cursors, comments
│
└── Build Social Media Dashboard
└── Live feed, notifications, analytics
Kelas DevOps & Infrastructure
📚 PRODUCTION READY
├── Docker untuk Developer
│ └── Containerization, compose, deployment
│
├── Redis Mastery
│ └── Caching, pub/sub, session management
│
├── Deploy ke Production
│ └── VPS, CI/CD, monitoring, scaling
│
└── WebSocket Scaling Strategies
└── Load balancing, Redis adapter, monitoring
Langkah Selanjutnya
Setelah membaca artikel ini, berikut recommended path:
STEP-BY-STEP:
1️⃣ WEEK 1-2: Fundamentals
├── Pilih satu backend framework
├── Setup basic WebSocket server
└── Implement simple chat
2️⃣ WEEK 3-4: Advanced Features
├── Add authentication
├── Implement rooms/channels
└── Add typing indicators & read receipts
3️⃣ WEEK 5-6: Production Features
├── Add reconnection logic
├── Implement rate limiting
└── Add basic monitoring
4️⃣ WEEK 7-8: Scaling
├── Setup Redis adapter
├── Configure load balancer
└── Load test & optimize
5️⃣ ONGOING: Real Project
├── Build complete property platform
├── Or implement WebSocket di existing project
└── Monitor, iterate, improve
Closing
WebSocket adalah teknologi yang powerful untuk membuat aplikasi yang engaging dan responsive. User modern expect real-time updates — bukan refresh manual setiap menit.
Untuk platform properti, WebSocket bukan lagi "nice to have" tapi sudah menjadi expectation. Chat dengan agen harus instant. Status properti harus akurat. Auction harus fair dan transparent.
Dengan pemahaman dari artikel ini dan practice yang konsisten, kamu bisa:
- Meningkatkan user experience platform yang kamu bangun
- Memberikan competitive advantage dibanding kompetitor
- Membuka peluang karir sebagai developer yang menguasai real-time technology
- Membangun produk yang users actually love to use
Technology terus berkembang, tapi fundamental real-time communication akan tetap relevan. WebSocket hari ini, WebTransport besok, tapi konsepnya sama — instant, bidirectional, engaging.
Start small. Build incrementally. Scale wisely.
Mulai dari satu fitur — mungkin live chat. Pastikan itu work dengan baik. Kemudian tambah presence, notifications, dan fitur lainnya. Setiap step, test thoroughly dan monitor closely.
"Real-time bukan tentang technology, tapi tentang experience. Technology adalah enabler, user experience adalah goal."
Selamat belajar dan building!
— Angga Risky Setiawan Founder, BuildWithAngga
Explore Kelas di BuildWithAngga
Kunjungi buildwithangga.com untuk explore semua kelas yang tersedia. Dari fundamentals sampai advanced, dari web development sampai mobile, dari solo projects sampai team collaboration.
Pilih path yang sesuai dengan goals kamu. Start learning. Build something amazing.