Jika Supabase Realtime mengirim event insert atau update lebih dari sekali, masalahnya sering bukan pada database yang benar-benar menyimpan data ganda, tetapi pada aplikasi yang memiliki lebih dari satu subscription aktif untuk channel yang sama. Gejalanya biasanya jelas: UI menampilkan item duplikat, log callback muncul berkali-kali untuk satu perubahan data, atau proses lanjutan yang seharusnya idempoten malah jalan berulang.
Dalam praktiknya, akar masalah paling umum adalah subscribe dipanggil ulang saat re-render, unsubscribe tidak berjalan saat komponen unmount atau dependency berubah, dan channel lama tetap hidup setelah session/auth berganti. Artikel ini fokus pada cara membedakan sumber masalahnya, mereproduksi bug secara minimal, lalu memperbaikinya dengan pola subscribe-once dan cleanup yang konsisten.
Gejala Nyata: Kapan Harus Curiga Ada Subscribe Berulang
Masalah ini biasanya muncul sebagai bug integrasi frontend-backend, bukan sekadar bug tampilan. Beberapa gejala yang paling sering terlihat:
- UI menampilkan item ganda setelah ada event insert.
- Status item berubah berkali-kali pada satu event update.
- Log callback aktif beberapa kali untuk channel atau tabel yang sama.
- Proses idempoten gagal karena satu event diproses lebih dari sekali, misalnya penambahan ke state lokal atau trigger workflow internal.
- Jumlah channel aktif terus bertambah setelah navigasi halaman, login/logout, atau refresh data.
Contoh gejala di UI:
// Harusnya 1 item baru, tapi hasilnya 2 atau 3 item identik di state lokal.Contoh gejala di log:
[realtime] event=INSERT channel=tasks user=123 row=42
[realtime] event=INSERT channel=tasks user=123 row=42
[realtime] event=INSERT channel=tasks user=123 row=42Jika row id sama, timestamp mirip, dan callback dipanggil berkali-kali dalam satu klien, kemungkinan besar masalah ada pada lifecycle subscription di aplikasi Anda.
Root Cause Utama Event Duplikat di Supabase Realtime
1. Subscribe dipanggil berulang saat re-render
Ini paling umum pada aplikasi React, Next.js, atau framework lain yang merender ulang komponen ketika state atau props berubah. Jika pembuatan channel dan pemanggilan subscribe berada di area yang ikut dieksekusi ulang tanpa guard, maka satu komponen bisa membuat banyak subscription identik.
Pola rawan:
// Jangan lakukan ini di body komponen atau di effect tanpa dependency yang benar
const channel = supabase
.channel('tasks')
.on('postgres_changes', { event: '*', schema: 'public', table: 'tasks' }, payload => {
console.log('event', payload)
})
.subscribe()Mengapa ini bermasalah? Karena setiap render dapat membuat channel baru. Walaupun nama channel sama, klien bisa tetap punya beberapa callback aktif bila channel lama tidak dibersihkan dengan benar.
2. Cleanup unsubscribe tidak jalan
Subscription Realtime adalah resource yang harus dibersihkan. Jika komponen unmount, dependency effect berubah, atau halaman berganti, channel lama harus dilepas. Tanpa cleanup, callback lama akan tetap menerima event.
Penyebab cleanup gagal antara lain:
- Fungsi cleanup tidak dikembalikan dari lifecycle hook.
- Referensi channel hilang sehingga tidak bisa di-unsubscribe.
- Cleanup hanya menghapus sebagian handler, bukan channel yang sebenarnya aktif.
- Kondisi early return membuat subscribe berjalan, tetapi cleanup tidak terpasang.
3. Channel lama tetap hidup setelah auth/session berubah
Saat user login, logout, atau token diperbarui, beberapa aplikasi membangun ulang klien atau subscription. Jika channel lama tidak dilepas sebelum membentuk subscription baru, Anda akan mendapat callback ganda dari channel lama dan baru. Ini sering terjadi pada aplikasi yang:
- Mengganti user context tanpa reset subscription.
- Menyimpan klien Supabase dalam scope yang berubah-ubah.
- Melakukan subscribe ulang setiap kali session berubah, tetapi tidak melakukan teardown yang simetris.
4. Guard reconnect tidak ada
Pada koneksi WebSocket yang sempat putus lalu tersambung lagi, aplikasi kadang menjalankan logika subscribe ulang secara manual. Jika library sudah melakukan pemulihan koneksi, subscribe manual tambahan dapat menambah callback baru di atas yang lama.
Catatan: Event yang terlihat ganda tidak selalu berarti Supabase mengirim satu event yang sama dua kali secara salah. Dalam banyak kasus, satu perubahan database dikirim benar sekali, tetapi diterima oleh beberapa callback aktif di klien yang sama.
Cara Membedakan Bug Aplikasi dari Duplikasi Data di Database
Sebelum mengubah kode subscription, pastikan dulu sumber duplikasinya. Ada dua kemungkinan besar:
- Data benar-benar terduplikasi di database karena insert dijalankan lebih dari sekali.
- Data di database benar, tetapi event diproses berkali-kali oleh beberapa subscriber aktif.
Verifikasi dari database
Lakukan pengecekan langsung ke tabel yang relevan berdasarkan primary key, kolom unik, atau timestamp.
-- Contoh umum untuk memeriksa apakah row benar-benar ganda
select id, created_at, updated_at
from tasks
where id = 42;Jika hanya ada satu row di database, tetapi UI menampilkan dua item, hampir pasti masalah ada di sisi aplikasi: state ditambah dua kali atau ada dua callback aktif.
Bedakan duplikasi event dan duplikasi rendering
Kadang callback memang terpanggil sekali, tetapi reducer atau state update Anda menambahkan item tanpa cek id. Jadi pastikan memeriksa dua lapisan:
- Apakah callback terpanggil lebih dari sekali?
- Apakah state update bersifat idempoten?
Contoh kesalahan umum:
// Rawan duplikat di UI walau event datang sekali
setTasks(prev => [payload.new, ...prev])Pola yang lebih aman:
setTasks(prev => {
const next = prev.filter(item => item.id !== payload.new.id)
return [payload.new, ...next]
})Perbaikan state seperti di atas tidak menggantikan cleanup subscription, tetapi membantu agar UI lebih tahan terhadap event berulang.
Langkah Investigasi: Reproduksi Minimal dan Inspeksi Lifecycle Channel
1. Buat reproduksi minimal
Kurangi kompleksitas. Jangan mulai dari aplikasi penuh. Buat satu halaman atau satu komponen yang:
- Melakukan subscribe ke satu tabel, misalnya
tasks. - Mencatat setiap event ke log.
- Menampilkan jumlah channel aktif.
- Tidak memuat logika bisnis lain.
Tujuannya adalah memastikan apakah bug muncul karena lifecycle komponen atau karena pipeline aplikasi yang lebih besar.
2. Log nama channel dan identitas handler
Tambahkan logging setiap kali subscription dibuat dan dibersihkan. Jangan hanya log payload event. Log juga konteks lifecycle-nya.
let handlerSeq = 0
function createTasksSubscription(supabase, label) {
const handlerId = ++handlerSeq
const channelName = `tasks-${label}`
console.log('[realtime] create', { channelName, handlerId })
const channel = supabase
.channel(channelName)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'tasks' },
payload => {
console.log('[realtime] event', {
channelName,
handlerId,
event: payload.eventType,
rowId: payload.new?.id ?? payload.old?.id
})
}
)
channel.subscribe(status => {
console.log('[realtime] status', { channelName, handlerId, status })
})
return () => {
console.log('[realtime] cleanup', { channelName, handlerId })
supabase.removeChannel(channel)
}
}Dengan log semacam ini, Anda bisa melihat pola seperti:
createdipanggil 3 kali, tetapicleanuptidak pernah muncul.- Session berubah, channel baru dibuat, channel lama tetap menerima event.
- Satu event database menghasilkan log dari beberapa
handlerId.
3. Inspeksi jumlah channel aktif
Jika lingkungan aplikasi Anda memungkinkan, log daftar channel aktif di klien setelah subscribe dan setelah cleanup. Intinya bukan bergantung pada implementasi internal tertentu, melainkan memastikan jumlah channel naik dan turun secara simetris.
console.log('[realtime] active channels check before/after subscribe')Yang Anda cari adalah pola kebocoran: setiap buka halaman jumlah channel bertambah, tetapi saat pindah halaman tidak berkurang.
4. Uji skenario auth/session
Bug ini sering tidak muncul saat halaman pertama kali dimuat, tetapi muncul setelah:
- login lalu logout,
- refresh token/session,
- berpindah tenant/workspace,
- navigasi bolak-balik antar halaman.
Karena itu, reproduksi minimal harus mencakup skenario ini. Jika setelah pergantian session callback menjadi dobel, fokuskan debugging pada teardown channel saat context user berubah.
Perbaikan Konkret: Pola Subscribe-Once dan Cleanup yang Benar
Pola dasar yang aman
Prinsip utamanya:
- Buat subscription sekali per lifecycle yang memang dibutuhkan.
- Simpan referensi channel secara stabil.
- Lakukan cleanup di tempat yang pasti dipanggil saat dependency berubah atau komponen unmount.
- Jangan subscribe ulang tanpa alasan yang jelas.
Contoh pola React yang lebih aman:
import { useEffect, useRef } from 'react'
function useTasksRealtime(supabase, onEvent, sessionKey) {
const channelRef = useRef(null)
useEffect(() => {
if (!supabase || !sessionKey) return
// Guard: jika masih ada channel lama, lepas dulu
if (channelRef.current) {
supabase.removeChannel(channelRef.current)
channelRef.current = null
}
const channel = supabase
.channel(`tasks-${sessionKey}`)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'tasks' },
payload => {
onEvent(payload)
}
)
channel.subscribe(status => {
console.log('[realtime] tasks status', status)
})
channelRef.current = channel
return () => {
if (channelRef.current) {
supabase.removeChannel(channelRef.current)
channelRef.current = null
}
}
}, [supabase, sessionKey, onEvent])
}Ada dua ide penting di sini:
- Cleanup sebelum membuat channel baru untuk mencegah channel lama tertinggal saat dependency berganti.
- Cleanup saat unmount agar callback lama tidak hidup terus.
Jika onEvent sering berubah identitas pada setiap render, pertimbangkan menstabilkannya agar effect tidak ikut jalan ulang tanpa perlu. Intinya, dependency yang berubah-ubah dapat memicu subscribe ulang walaupun tabel dan user tidak berubah.
Gunakan state update yang idempoten
Walaupun akar masalahnya adalah subscription ganda, state update sebaiknya tetap tahan terhadap event berulang. Ini berguna saat reconnect atau saat aplikasi menerima event yang sama dari dua jalur berbeda.
function applyTaskUpsert(tasks, task) {
const index = tasks.findIndex(t => t.id === task.id)
if (index === -1) return [task, ...tasks]
const copy = [...tasks]
copy[index] = { ...copy[index], ...task }
return copy
}Pendekatan ini membuat insert/update di UI lebih mendekati semantik upsert, bukan selalu append.
Jangan gabungkan subscribe dengan render logic biasa
Kesalahan desain yang sering terjadi adalah menyatukan inisialisasi Realtime dengan fungsi yang juga menangani fetch, sorting, filter, atau transformasi state. Akibatnya, perubahan kecil di UI memicu subscribe ulang.
Lebih aman jika Anda memisahkan:
- initial fetch data,
- realtime subscription,
- state reconciliation saat event masuk.
Pemisahan ini memudahkan debugging karena jelas bagian mana yang membuat duplikasi.
Guard terhadap Reconnect dan Perubahan Session
Hindari subscribe ulang buta saat koneksi pulih
Jika Anda punya mekanisme sendiri untuk menangani putus-sambung koneksi, jangan langsung menambahkan subscribe baru tanpa memeriksa apakah channel lama masih terdaftar. Pendekatan yang lebih aman adalah:
- gunakan satu jalur pembuatan subscription,
- pusatkan teardown dan setup dalam satu fungsi,
- pastikan setiap setup didahului cleanup pada referensi channel yang lama.
Prinsip ini mencegah akumulasi handler setelah jaringan tidak stabil.
Reset channel saat auth berubah
Saat session berubah, anggap subscription lama tidak lagi valid. Lakukan siklus yang jelas:
- lepaskan channel lama,
- perbarui konteks auth bila diperlukan,
- buat channel baru yang terkait dengan session saat ini.
Jangan mempertahankan channel lama sambil membuat yang baru. Pada bug nyata, ini sering menjadi sumber log callback dobel setelah login ulang.
Checklist Debugging Supabase Realtime Event Duplikat
- Apakah event yang sama benar-benar muncul lebih dari sekali di callback?
- Apakah row di database hanya satu, tetapi UI menampilkan lebih dari satu?
- Apakah subscribe dipanggil dari area yang bisa re-render berkali-kali?
- Apakah setiap subscribe punya cleanup yang benar-benar dieksekusi?
- Apakah channel lama dilepas saat komponen unmount?
- Apakah channel lama dilepas saat dependency effect berubah?
- Apakah pergantian auth/session membuat subscription baru tanpa menghapus yang lama?
- Apakah reconnect memicu subscribe ulang manual di atas subscription yang sudah ada?
- Apakah state update di UI append terus tanpa deduplikasi berdasarkan id?
- Apakah Anda punya logging untuk channel name, handler id, dan status subscribe/cleanup?
Common Mistakes yang Sering Menjebak
Mengira semua duplikasi berasal dari Supabase
Sering kali sumbernya adalah lifecycle aplikasi sendiri. Sebelum menyalahkan transport event, cek lebih dulu jumlah callback aktif di klien.
Mengandalkan nama channel yang sama sebagai jaminan tunggal
Nama channel yang identik tidak otomatis berarti hanya ada satu callback aktif. Yang penting adalah bagaimana channel dibuat, disimpan, dan dibersihkan.
Hanya memperbaiki UI, bukan akar masalah
Deduplikasi di state memang membantu, tetapi tidak menyelesaikan kebocoran subscription. Callback ganda tetap memboroskan resource, membingungkan log, dan bisa merusak proses bisnis lain.
Tidak menguji navigasi dan auth flow
Bug subscription sering baru muncul setelah halaman dibuka-tutup beberapa kali atau setelah user berganti session. Uji skenario ini sejak awal.
Penutup
Kasus Debug Supabase Realtime: Event Duplikat karena Subscribe Berulang hampir selalu berkaitan dengan lifecycle subscription yang tidak terkendali. Tanda utamanya adalah satu perubahan data memicu beberapa callback aktif di klien yang sama. Fokus investigasi yang efektif adalah membuktikan apakah database benar-benar ganda atau aplikasi hanya memproses event yang sama lebih dari sekali.
Perbaikan yang paling stabil adalah mengadopsi pola subscribe-once, memastikan cleanup unsubscribe selalu berjalan, dan mereset channel saat auth/session berubah. Tambahkan juga state update yang idempoten dan logging channel yang memadai. Dengan kombinasi ini, UI tidak lagi menampilkan item ganda, proses downstream lebih aman, dan debugging menjadi jauh lebih terarah.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!