Notifikasi realtime memberi tahu satu orang tentang kejadian yang menyangkut dirinya, saat itu juga, lewat jalan yang masih bisa sampai. Bayangin Bu Rini mau mengabari Pak Darto bahwa cucian #12 siap diambil. Kalau Pak Darto ada di toko, cukup dipanggil (notifikasi in-app). Kalau ia di rumah dengan HP di saku, Bu Rini titip ke operator yang bisa membunyikan HP-nya walaupun aplikasinya tertutup (Web Push). Kalau itu juga gak bisa, kirim WhatsApp: berbayar, tapi pasti terbaca. Dan semua kabar dicatat di buku, supaya bisa dibaca belakangan (pusat notifikasi).
🧪 Tebakan cepat: Pak Darto membuka aplikasi laundry di iPhone lewat tab Safari biasa, belum ditambahkan ke layar utama. Bisakah ia menerima Web Push setelah tab-nya ditutup? Jawabannya ada di bagian "Web Push: kabar saat aplikasi tertutup".
Satu kejadian, beberapa jalan
Saat Mas Eko menandai #12 Siap diambil, server Laravel memanggil server realtime:
async function kabari(userId, data) {
const notif = await db.notifikasi.simpan({ userId, ...data, dibaca: false }) // 1. simpan dulu
const online = io.of('/').adapter.rooms.get(`user:${userId}`)?.size > 0
if (online) return io.to(`user:${userId}`).emit('notif:new', notif) // 2. in-app
for (const sub of await db.langganan.milik(userId)) { // 3. Web Push
await kirimPush(sub, { title: data.title, url: `/pesanan/${data.order}` })
}
}
- Setiap HP pelanggan ikut room pribadinya,
user:darto. Mengirim ke room itu adalah cara paling efisien untuk satu user: cuma koneksi miliknya yang menerima. Menyiarkan ke semua lalu menyaring di browser itu boros dan membocorkan kabar Pak Darto ke HP orang lain. - Disimpan dulu ke database, supaya pusat notifikasi bisa membacanya walaupun semua jalan gagal.
- Kalau Pak Darto sedang online (room-nya gak kosong, pelajaran Presence), in-app sudah cukup. Kalau gak, baru Web Push.
| Jalan | Sampai kalau | Butuh | Biaya |
|---|---|---|---|
| In-app (socket) | aplikasi sedang terbuka | gak ada | gratis |
| Web Push | aplikasi tertutup juga | izin, service worker, langganan | gratis |
| WhatsApp atau SMS | kapan pun, tanpa aplikasi | nomor HP, akun bisnis | bayar per pesan |
Web Push: kabar saat aplikasi tertutup
- Pak Darto menekan tombol "Kabari saya", dan browser bertanya izin (
Notification.requestPermission()). - Halaman mendaftarkan service worker (
/sw.js): skrip yang bisa dibangunkan browser walaupun tab-nya tertutup. pushManager.subscribe(...)meminta alamat ke push service milik browser (FCM untuk Chrome, Mozilla untuk Firefox, Apple untuk Safari). Hasilnya subscription:endpoint(sebuah URL) dan dua kunci enkripsi.- Subscription dikirim ke server dan disimpan per perangkat.
- Untuk mengabari, server mengenkripsi isinya dengan kunci itu, menandatanganinya dengan kunci privat VAPID (bukti bahwa pengirimnya pemilik kunci publik yang dipakai saat subscribe), lalu POST ke
endpoint. Push service menjawab 201 dan mengantar ke HP begitu HP online. - Browser membangunkan service worker, yang wajib menampilkan notifikasi.
// halaman: di dalam handler klik tombol "Kabari saya"
const izin = await Notification.requestPermission() // 'granted', 'denied', atau 'default'
if (izin === 'granted') {
const reg = await navigator.serviceWorker.register('/sw.js')
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY })
await fetch('/api/push/langganan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub) })
}
// sw.js
self.addEventListener('push', (event) => {
const data = event.data.json()
event.waitUntil(self.registration.showNotification(data.title, { data: { url: data.url } }))
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(clients.openWindow(event.notification.data.url))
})
userVisibleOnly: trueadalah janji bahwa setiap push menampilkan notifikasi. Chrome menolak subscribe tanpa itu, dan juga mewajibkanapplicationServerKey.- Jawaban 404 atau 410 dari
endpointartinya langganan itu sudah mati (izin dicabut, aplikasi dihapus). Hapus dari database. - Isinya kecil: push service wajib menerima minimal 4.096 byte. Kirim judul dan URL, bukan seluruh order. Di server Node, library
web-pushmengurus enkripsi dan tanda tangannya. - iPhone dan iPad: Web Push ada sejak iOS 16.4, tapi cuma untuk web app yang ditambahkan ke layar utama, dengan manifest yang
display-nya bukan bawaan (misalnyastandalone). Di tab Safari biasa,Notificationbahkan gak ada (undefined). Itu jawaban tebakannya: gak bisa. Di Mac, Safari 16 ke atas di macOS Ventura (data browser-compat-data 8.1.5). - Android Chrome:
new Notification(...)di halaman selalu melemparTypeError. Notifikasi harus lewatregistration.showNotification().
Kapan minta izin
- Jangan saat halaman dibuka. Firefox (sejak versi 72) cuma memunculkan prompt dari aksi pengguna seperti klik, begitu juga Safari. Orang yang kaget juga cenderung menolak.
- Tanya di saat yang masuk akal: setelah Pak Darto menyerahkan cucian, tampilkan tombol "Kabari saya kalau sudah siap". Prompt browser baru muncul setelah ia menekannya.
- Jawaban
'denied'itu permanen untuk situs itu:requestPermission()berikutnya langsung menjawab'denied'tanpa prompt. Cuma Pak Darto yang bisa mengubahnya di pengaturan browser.
WhatsApp dan SMS
Sampai ke siapa pun yang punya nomor, tanpa aplikasi dan tanpa izin browser, dan pelanggan laundry di Indonesia memang membaca WhatsApp. Tapi bayar per pesan, dan pesan WhatsApp yang dimulai bisnis harus memakai template yang disetujui lebih dulu. Pakai untuk kabar penting saja (siap diambil), atau sebagai cadangan kalau notifikasi gak terbaca dalam 30 menit.
Pusat notifikasi dan angka belum dibaca
Angka di ikon lonceng harus tetap benar setelah reload, reconnect, atau dibaca dari perangkat lain. Jadi sumbernya database, dan socket cuma memperbaruinya di antara dua pengambilan:
let belumDibaca = new Map() // id -> notifikasi
socket.on('connect', async () => {
const daftar = await fetch('/api/notifikasi?belum-dibaca=1').then((r) => r.json())
belumDibaca = new Map(daftar.map((n) => [n.id, n])) // tiap tersambung: ambil ulang
gambarLonceng()
})
socket.on('notif:new', (n) => { belumDibaca.set(n.id, n); gambarLonceng() }) // id sama: ditimpa
socket.on('notif:read', ({ id }) => { belumDibaca.delete(id); gambarLonceng() }) // dibaca di HP lain
function gambarLonceng() {
lonceng.textContent = belumDibaca.size // dihitung dari isi, bukan +1 dan -1
}
- Angka dihitung dari isi
Mapperid. Notifikasi yang datang dua kali menimpa dirinya sendiri, gak menambah angka. - Penghitung
+1dan-1di browser lama-lama melenceng: event saat putus hilang, event dobel menambah dua, dan bacaan dari tablet lain gak terhitung. - Saat dibaca,
POST /api/notifikasi/301/dibaca, lalu server emitnotif:readke roomuser:darto, supaya semua perangkat Pak Darto ikut berubah.
⚠️ Jebakan yang sering ketemu
- Minta izin begitu halaman dibuka. Ditolak sekali, situsmu gak bisa bertanya lagi.
- Siarkan ke semua lalu saring di client. Kabar Pak Darto sampai ke HP orang lain. Kirim ke room
user:darto. - Angka belum dibaca disimpan sebagai penghitung lokal. Ambil ulang dari server setiap tersambung.
- Mengandalkan Web Push di tab Safari iPhone. Cuma jalan dari layar utama. Sediakan in-app, dan WhatsApp untuk kabar penting.
TL;DR: Simpan notifikasi ke database dulu, lalu kirim ke room pribadi user:darto (cara paling efisien untuk satu user) kalau ia online, atau lewat Web Push kalau aplikasinya tertutup. Web Push butuh izin (diminta dari tombol, bukan saat halaman dibuka; denied itu permanen), service worker, dan subscription dari push service browser; server mengirim isi terenkripsi bertanda tangan VAPID ke endpoint, dan service worker wajib memanggil showNotification. Di iOS 16.4 ke atas, Web Push cuma untuk web app di layar utama. WhatsApp dan SMS sampai ke siapa pun tapi berbayar. Angka belum dibaca dihitung dari daftar per id yang diambil ulang dari server setiap tersambung, bukan dari penghitung lokal.