Merancang aplikasi chat seperti WhatsApp artinya merancang jalan sebuah pesan: dari HP pengirim, disimpan, lalu sampai ke setiap penerima secepat mungkin, termasuk yang sedang offline. Bayangin kantor pos di satu kompleks. Setiap surat dicatat dulu di buku besar dengan nomor urut. Surat lalu diantar ke rumah yang penghuninya ada. Rumah yang kosong dapat kertas "ada kiriman" di pintu, dan suratnya diantar begitu penghuninya pulang. Catat dulu, beri nomor, antar ke yang ada, kabari yang gak ada: itu seluruh desainnya.
🧪 Tebakan cepat: Budi mengirim satu pesan ke grup "Panitia Reuni" yang beranggotakan 5 orang, termasuk Budi. Dewi sedang offline. Berapa kali pesan itu ditulis ke database, dan berapa kali dikirim lewat WebSocket? Tebak dulu, jawabannya ada di bagian "Perjalanan satu pesan".
Requirements dan estimasi Ngobrol
Di lesson ini kita merancang Ngobrol, aplikasi chat. Ini desain gaya interview yang berdiri sendiri, jadi bukan TiketNada.
| Functional | Non-functional |
|---|---|
| Chat 1-on-1 dan grup | Pesan sampai ke penerima yang online di bawah 200 ms |
| Pesan untuk yang offline sampai saat dia online | Pesan yang sudah "terkirim" gak boleh hilang |
| Status terkirim, sampai, dibaca | Urutan pesan di satu percakapan sama di semua HP |
| Status online dan "terakhir dilihat" | Riwayat terbaru terbuka cepat |
Angka Ngobrol (model contoh), dihitung seperti di lesson Apa itu System Design:
| Yang dihitung | Hitungan | Hasil |
|---|---|---|
| Pesan per hari | 10.000.000 pengguna × 40 pesan | 400.000.000 |
| Pesan per detik | 400.000.000 ÷ 86.400 | ≈ 4.630 |
| Storage per hari | 400.000.000 × 200 byte | 80 GB |
| Storage per tahun | 80 GB × 365 | 29,2 TB |
| Koneksi saat puncak | 30% pengguna online bersamaan | 3.000.000 |
| Server WebSocket | 3.000.000 ÷ 50.000 koneksi per server | 60 |
| Heartbeat presence | 3.000.000 ÷ 30 detik | 100.000 per detik |
Baris terakhir sering dilupakan: cuma untuk menjaga status online, sistem menerima 100.000 tulisan per detik, jauh lebih banyak dari pesannya sendiri.
Kenapa WebSocket
HTTP biasa cuma bisa menjawab: server gak bisa mengirim pesan ke HP Rina kalau Rina gak bertanya duluan. Ada tiga cara supaya pesan baru sampai:
- Polling: HP bertanya tiap beberapa detik "ada pesan baru?". Hampir semua jawabannya "belum", jadi boros baterai dan request.
- SSE (Server-Sent Events): koneksi terbuka, tapi cuma satu arah, dari server ke client.
- WebSocket: satu koneksi yang tetap terbuka dan dua arah. Server bisa mengirim kapan saja, client juga.
Chat butuh dua arah dengan latency sangat rendah. Contohnya indikator "Siti sedang mengetik...": event kecil yang dikirim berkali-kali per menit. Lewat WebSocket, event itu tinggal dikirim di koneksi yang sudah terbuka. Cara kerja WebSocket-nya dibahas di lesson WebSocket (track Node.js).
Karena koneksinya menetap, server chat itu stateful: dia memegang ribuan koneksi yang terbuka. Satu server menampung sekitar 50.000 koneksi di model ini, jadi butuh 60 server, dan pengirim dan penerima sering ada di server yang berbeda.
Perjalanan satu pesan
Grup Panitia Reuni: Budi dan Siti tersambung ke server ws-1; Rina, Andi, dan Dewi ke ws-2. Dewi offline. Budi mengirim "Rapat reuni Sabtu jam 7 malam":
| Langkah | Yang terjadi |
|---|---|
| 1 | ws-1 memberi pesan nomor urut seq 1 untuk percakapan ini |
| 2 | ws-1 menyimpan pesan ke message store: 1 tulisan |
| 3 | ws-1 mengirim ack ke Budi. Di HP Budi: "terkirim" |
| 4 | Siti di ws-1 yang sama: dikirim langsung lewat WebSocket-nya |
| 5 | Rina dan Andi di ws-2: ws-1 mem-publish pesan ke channel ws-2 di Redis Pub/Sub, lalu ws-2 mengirimnya ke mereka |
| 6 | Dewi offline: pesan dicatat "tertunda" untuk Dewi, dan push notification dikirim |
Jawaban tebakannya: 1 tulisan ke database, 3 kiriman WebSocket (Siti, Rina, Andi), plus 1 push notification untuk Dewi. Pesannya disimpan sekali, lalu disebar ke banyak penerima. Penyebaran ini disebut fan-out. Redis Pub/Sub adalah fitur Redis untuk mengirim pesan ke semua yang berlangganan sebuah channel; di sini tiap server chat berlangganan channel-nya sendiri.
// Ngobrol: grup "Panitia Reuni" di dua server WebSocket (model contoh)
const serverOf = { Budi: "ws-1", Siti: "ws-1", Rina: "ws-2", Andi: "ws-2", Dewi: "ws-2" };
const online = new Set(["Budi", "Siti", "Rina", "Andi"]); // Dewi offline
const pesan = []; // message store, urut per seq
const tertunda = { Dewi: [] }; // seq yang belum sampai ke Dewi
function kirim(teks) {
const seq = pesan.length + 1; // nomor urut per percakapan
pesan.push({ seq, teks }); // simpan dulu, baru kirim
console.log(`seq ${seq} tersimpan, ack ke Budi`);
const perServer = {};
for (const nama of Object.keys(serverOf)) {
if (nama === "Budi") continue;
if (!online.has(nama)) {
tertunda[nama].push(seq);
console.log(`${nama} offline: push notification`);
} else {
(perServer[serverOf[nama]] ??= []).push(nama);
}
}
for (const [server, penerima] of Object.entries(perServer)) {
const jalur = server === "ws-1" ? "langsung" : "lewat Redis Pub/Sub";
console.log(`${server} ${jalur}: ${penerima.join(", ")}`);
}
}
function sambung(nama) {
online.add(nama);
console.log(`${nama} online: kirim seq ${tertunda[nama].join(", ") || "(tidak ada)"}`);
tertunda[nama] = [];
}
kirim("Rapat reuni Sabtu jam 7 malam");
sambung("Dewi");
Output-nya:
seq 1 tersimpan, ack ke Budi
Dewi offline: push notification
ws-1 langsung: Siti
ws-2 lewat Redis Pub/Sub: Rina, Andi
Dewi online: kirim seq 1
pesan.pushterjadi sebelum apa pun dikirim. Ack ke Budi artinya "pesanmu sudah aman di database", bukan "sudah dibaca".seqdiberikan server, satu deret per percakapan. HP gak ikut menentukan nomornya.perServermengelompokkan penerima per server: satu publish kews-2cukup untuk Rina dan Andi.tertundamenyimpan nomor pesan yang belum sampai ke Dewi.sambungmengirim semuanya, urut, begitu Dewi online.
Urutan pesan
Satu koneksi TCP menjaga urutan, tapi pesan melewati beberapa jalur: Budi bisa mengirim dari HP dan laptop, dua pesan bisa ditangani dua proses berbeda, dan publish antarserver bisa di-retry. Jadi dua pesan yang dikirim beruntun bisa tiba terbalik di HP Rina: seq 2 dulu, baru seq 1.
Obatnya: HP menampilkan pesan menurut seq, bukan menurut waktu tiba. Jangan pakai jam HP pengirim, karena jam tiap perangkat gak pernah sama (lesson Kenapa Distributed System Sulit). Seq juga membuat celah ketahuan: kalau HP punya seq 5 lalu menerima seq 7, dia tahu seq 6 belum sampai dan memintanya ke server.
Offline, push notification, dan read receipt
Pesan untuk yang offline gak dibuang dan gak dikirim ulang terus-menerus. Langkahnya:
- Pesan tersimpan di database dulu (sudah terjadi di langkah 2).
- Server mengirim push notification lewat FCM (Android) atau APNs (iPhone), layanan Google dan Apple yang bisa membangunkan aplikasi yang tertutup.
- Saat Dewi online, HP-nya bilang "seq terakhir yang aku punya: 0", dan server mengirim seq 1 dan seterusnya.
Tanda centang di HP Budi adalah tiga status: terkirim (ack, tersimpan di server), sampai (sudah diterima HP penerima), dibaca (penerima membuka chat-nya). Status "sampai" dan "dibaca" cuma event kecil yang dikirim balik lewat WebSocket, misalnya { type: "read", upTo: 3 }: "aku sudah membaca sampai seq 3".
Presence: online atau terakhir dilihat
HP yang online mengirim heartbeat (ping kecil) tiap 30 detik. Server menyimpannya di Redis dengan TTL 60 detik, misalnya SET presence:dewi online EX 60. Selama ping datang, key-nya diperpanjang. Kalau HP hilang sinyal tanpa sempat menutup koneksi, key-nya kedaluwarsa sendiri paling lambat 60 detik kemudian, dan statusnya jadi "terakhir dilihat".
Menyimpan riwayat
Semua pesan disimpan permanen di database yang dipartisi per conversation_id dan diurutkan per seq, misalnya Cassandra/ScyllaDB (kuat untuk tulisan banyak) atau PostgreSQL yang dipartisi. Orang hampir selalu membaca pesan terbaru, jadi sekitar 50 pesan terakhir tiap percakapan di-cache di Redis: database untuk persistensi, cache untuk pesan terbaru yang sering dibuka. Foto dan video gak masuk database chat: disimpan di object storage dan dikirim lewat CDN.
🤔 Grup besar: fan-out on write atau on read
Fan-out yang tadi disebut fan-out on write: saat pesan ditulis, langsung disebar ke tiap anggota. Grup 1.000 orang berarti 1 tulisan dan 999 kiriman per pesan. Untuk channel 100.000 anggota, satu pesan jadi 99.999 kiriman, dan kebanyakan anggota bahkan gak membuka channel-nya. Di sana dipakai fan-out on read: pesan disimpan sekali, dan anggota mengambilnya saat membuka channel. Batasnya (misalnya 100 atau 1.000 anggota) adalah keputusan desain.
⚠️ Jebakan yang sering ketemu
- Kirim dulu, simpan belakangan. Kalau server mati di antaranya, pesan yang sudah "terkirim" hilang. Simpan dulu, baru ack dan kirim.
- Mengurutkan pakai waktu tiba atau jam HP. Urutan tiba bisa terbalik dan jam HP bisa salah. Pakai seq dari server.
- Lupa bahwa penerima ada di server lain. Koneksi WebSocket Rina cuma ada di
ws-2. Tanpa Redis Pub/Sub (atau broker sejenis),ws-1gak bisa menjangkaunya. - Pesan offline disimpan di memori server. Server restart, pesan hilang. Yang tertunda harus ada di database.
TL;DR: Chat app: client dan server bicara lewat WebSocket, koneksi dua arah yang tetap terbuka (pas untuk pesan dan indikator mengetik; SSE cuma satu arah, polling boros). Satu pesan: server memberi seq per percakapan, menyimpannya sekali, ack ke pengirim ("terkirim"), lalu fan-out: langsung ke penerima di server yang sama, lewat Redis Pub/Sub ke server lain, dan push notification (FCM/APNs) ke yang offline, yang menerima semua pesan tertundanya saat online lagi. Tampilkan pesan menurut seq, bukan waktu tiba. Presence: heartbeat tiap 30 detik, TTL 60 detik di Redis. Riwayat: database dipartisi per percakapan + cache Redis untuk pesan terbaru. Ngobrol: 4.630 pesan per detik, 80 GB per hari, 3 juta koneksi di 60 server, 100.000 heartbeat per detik. Grup besar pakai fan-out on read.