Chat realtime membuat pesan dari satu orang muncul di layar orang lain dalam hitungan detik, lengkap dengan tanda terkirim, sampai, dan dibaca. Bayangin Pak Darto menitip surat lewat kurir. Ia menulis nomornya sendiri di amplop, supaya kalau kurir ragu lalu mengantar dua kali, Bu Rini tahu itu surat yang sama. Kantor pos memberi nomor arsip, supaya semua surat bisa diurutkan. Kurir membawa pulang tanda terima. Di chat order #12, nomor dari Pak Darto adalah cid, nomor arsip adalah id dari server, dan tanda terima adalah acknowledgement Socket.IO.
🧪 Tebakan cepat: Pak Darto mengirim "Bu, cucian saya jadi kapan?". Server sudah menyimpannya, tapi tanda terimanya gak sampai ke HP (sinyal jelek), jadi HP mengirim ulang. Berapa kali pesan itu muncul di layar kasir Bu Rini? Jawabannya ada di bagian "Server: simpan dulu, baru siarkan".
Bentuk satu pesan
{ "cid": "3f6c1e9a-8b2d-4c41-9f0e-5a7d2b1c8e44", "id": 58, "order": 12, "from": "darto",
"text": "Bu, cucian saya jadi kapan?", "at": "2026-10-11T09:20:05+07:00" }
cid: dibuat HP pengirim dengancrypto.randomUUID()sebelum dikirim. Gunanya mengenali kiriman ulang.id: nomor urut dari database server. Gunanya urutan dan riwayat.fromdanat: diisi server dari login dan jam server, bukan dari isi kiriman.
HP Pak Darto dan kasir Bu Rini sama-sama ada di room chat:12. Supaya contoh ini pendek, semua layar chat ada di namespace /.
Server: simpan dulu, baru siarkan
io.on('connection', (socket) => {
socket.on('chat:send', async (m, ack) => {
let pesan = await db.pesan.cariCid(m.cid) // kiriman ulang?
if (!pesan) {
pesan = await db.pesan.simpan({ cid: m.cid, order: m.order, from: socket.data.user, text: m.text })
io.to(`chat:${m.order}`).emit('chat:message', pesan) // baru siarkan setelah tersimpan
}
ack({ id: pesan.id, at: pesan.at }) // kiriman ulang dapat jawaban yang sama
})
})
- Pesan disimpan ke database sebelum disiarkan, supaya gak hilang kalau koneksi putus: apa pun yang pernah tampil di layar pasti ada di database, dan bisa dimuat ulang. Database jadi sumber kebenaran.
- Kiriman ulang dengan
cidyang sama ketemu di database, jadi gak disimpan dan gak disiarkan lagi, cuma dijawab. Itu jawaban tebakannya: pesannya muncul sekali di layar Bu Rini. Tanpa pengecekancid, database menyimpannya dua kali (id58 dan 59) dan menyiarkannya dua kali, jadi riwayat yang dimuat dari database menampilkan dua pesan. - Di database, beri
cidindeks unik, supaya dua kiriman yang datang bersamaan pun gak lolos dua-duanya.
Client: tampil dulu, pastikan kemudian
const pesan = new Map() // cid -> pesan: satu cid, satu baris
async function kirim(text) {
const m = { cid: crypto.randomUUID(), order: 12, text, status: 'mengirim' }
simpanLalu(m) // optimistic: langsung tampil dengan jam abu-abu
for (let coba = 1; coba <= 3; coba++) {
try {
const { id, at } = await socket.timeout(5000).emitWithAck('chat:send', m)
return simpanLalu({ ...m, id, at, status: 'terkirim' })
} catch {
// gak dijawab: coba lagi dengan cid yang SAMA
}
}
simpanLalu({ ...m, status: 'gagal' }) // tampilkan tombol "Kirim ulang"
}
socket.on('chat:message', (m) => simpanLalu({ ...m, status: 'terkirim' }))
function simpanLalu(m) {
pesan.set(m.cid, { ...pesan.get(m.cid), ...m })
render([...pesan.values()].sort((a, b) => (a.id ?? Infinity) - (b.id ?? Infinity)))
}
- Optimistic send: pesan tampil saat itu juga, sebelum server menjawab. Ack mengubahnya jadi terkirim.
io.to('chat:12')juga mengirim ke HP Pak Darto sendiri.Mapdengan kuncicidmembuat salinan dari server menimpa salinan optimistic, bukan menambah baris.- Urutan memakai
iddari server, bukan jam HP. Pesan yang belum punyaidada di bawah. - Dicoba dengan socket.io 4.8.4: kalau koneksi putus sebelum ack datang,
emitWithAcklangsung gagal dengansocket has been disconnected. Kiriman berikutnya menunggu di antrean, dikirim setelah tersambung, dan server menjawab denganidyang sama (58).
Centang: terkirim, sampai, dibaca
| Tanda | Artinya | Dari mana |
|---|---|---|
| ✓ terkirim | server sudah menyimpan | ack chat:send |
| ✓✓ sampai | layar Bu Rini sudah menerima | kasir emit chat:delivered { order: 12, upTo: 58 } |
| ✓✓ dibaca | Bu Rini membuka chat-nya | kasir emit chat:read { order: 12, upTo: 58 } |
Simpan satu angka per orang per chat ("Bu Rini sudah membaca sampai id 58"), bukan tanda di tiap pesan. Semua pesan dengan id sampai 58 dianggap dibaca.
Indikator mengetik
- HP Pak Darto mengirim
typingsaat ia mengetik, paling sering sekali tiap 2 detik (throttle), danstop-typingsaat pesan terkirim atau kolomnya dikosongkan. Mengetik 40 huruf selama 8 detik jadi 4 event, bukan 40. - Kasir Bu Rini menampilkan "Pak Darto sedang mengetik..." dan menyembunyikannya saat
stop-typingdatang, saat pesannya datang, atau 3 detik setelahtypingterakhir. Angka 3 dipilih lebih besar dari jeda 2 detik, supaya indikatornya gak berkedip, tapi tetap hilang kalaustop-typinggak pernah sampai (HP Pak Darto putus). - Server meneruskan dengan
socket.to('chat:12').emit('typing', ...)(bukan ke pengirim) dan gak menyimpannya.
Riwayat setelah tersambung lagi
Event yang disiarkan saat kasir Bu Rini putus gak akan sampai ke kasir (at-most-once, pelajaran Event-Based Architecture). Jadi setiap kali tersambung, kasir meminta yang terlewat:
socket.on('connect', async () => {
socket.emit('ikuti-chat', 12)
const baru = await fetch(`/chat/12/pesan?setelah=${idTerakhir()}`).then((r) => r.json())
baru.forEach((m) => simpanLalu({ ...m, status: 'terkirim' }))
})
idTerakhir() adalah id terbesar yang sudah ada di layar. Opsi connectionStateRecovery Socket.IO membantu untuk putus singkat (sampai 2 menit), tapi gak menggantikan ini: kasir bisa saja mati 10 menit.
Menampilkan teks dari orang lain
Isi chat ditulis pelanggan, jadi anggap berbahaya. Kalau Pak Darto (atau orang iseng) mengirim <img src=x onerror="alert('hai')">:
li.innerHTML = m.textmenjalankanonerrordi kasir Bu Rini. Ini XSS.li.textContent = m.textmenampilkannya sebagai teks biasa. React{m.text}juga aman, karena sudah di-escape.
⚠️ Jebakan yang sering ketemu
- Kirim ulang dengan
cidbaru. Server gak bisa mengenalinya, dan pesan muncul dua kali. Buatcidsekali, sebelum percobaan pertama. - Urutan dari jam HP. Jam HP pelanggan bisa selisih menit. Urutkan dengan
idserver. crypto.randomUUID()di tablet lewathttp://192.168.x.x. Dicoba di Chromium dan WebKit: di halaman yang bukan HTTPS atau localhost, fungsinya gak ada (TypeError: crypto.randomUUID is not a function). Pakai HTTPS, atau buatciddaricrypto.getRandomValues.innerHTMLuntuk isi chat. PakaitextContent.
TL;DR: Tiap pesan punya cid buatan HP pengirim (untuk mengenali kiriman ulang) dan id dari server (untuk urutan dan riwayat). Server menyimpan dulu ke database, baru menyiarkan ke room chat:12, dan menjawab kiriman ulang dengan id yang sama tanpa menyiarkan lagi. Client menampilkan pesan langsung (optimistic), menunggu ack dengan socket.timeout(5000).emitWithAck, mengirim ulang dengan cid yang sama, dan menyimpan pesan di Map per cid. Centang terkirim dari ack, sampai dan dibaca dari satu angka upTo per orang. Kirim typing paling sering tiap 2 detik dan stop-typing saat selesai; penerima menyembunyikan indikator setelah 3 detik tanpa kabar. Setiap tersambung, ambil pesan setelah id terakhir, dan tampilkan teks dengan textContent, bukan innerHTML.