Real-time Chat Application — Realtime

Membangun Chat App Real-time Chat app adalah "hello world" dari real-time. Terlihat simple tapi banyak detail: message ordering, read receipts, typing…

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" }

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
  })
})

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)))
}

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

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')">:

⚠️ Jebakan yang sering ketemu

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.

Yang akan kamu pelajari