Supabase Realtime en pratique : trois modes comparés et apps collaboratives

Vous fixez l’indicateur « en train d’écrire… », vous rafraîchissez la page de chat pour la dix-septième fois. Votre ami est pourtant en ligne ; les messages n’arrivent toujours pas. Construire une application vraiment « temps réel », c’est plus dur qu’on ne le croit.
Les pièges WebSocket sont nombreux : reconnexion, synchronisation d’état, conception du broadcast. L’an dernier, avec Supabase Realtime, j’ai découvert qu’on pouvait déléguer une bonne partie de ce fardeau. Supabase propose trois modes : Postgres Changes pour les changements en base, Presence pour l’état utilisateur, Broadcast pour les messages temporaires. Bien choisi, vous gagnez du temps ; mal choisi, vous vous creusez un trou.
Cet article détaille les trois modes — quand les utiliser, comment coder, comment configurer le RLS. En fin de parcours, on les combine dans une application de chat collaborative complète.
Comparaison des trois piliers de Supabase Realtime
En résumé : chaque mode a son rôle ; ne les mélangez pas au hasard.
Postgres Changes écoute les changements en base. Idéal pour messages de chat, notifications, statut de commande — tout ce qui doit être persisté. Les données restent en PostgreSQL ; le client s’abonne aux mises à jour.
Presence suit qui est en ligne. Parfait pour la liste des connectés, l’indicateur de frappe, le curseur en édition collaborative. Rien en base : tout est en mémoire ; à la déconnexion, c’est fini.
Broadcast diffuse des messages éphémères. Curseur sur un canvas, position en jeu, synchronisation d’actions légères. Contrairement à Presence : Broadcast pour l’envoi à haute fréquence, Presence pour la synchronisation d’état.
Tableau récapitulatif :
| Mode | Stockage | Cas typiques | Persistance |
|---|---|---|---|
| Postgres Changes | PostgreSQL | Messages, notifications, commandes | Oui |
| Presence | Mémoire (service Realtime) | Utilisateurs en ligne, indicateur de saisie | Non |
| Broadcast | Aucun (relai instantané) | Curseur, position temps réel | Non |
Vous vous demandez peut-être : pourquoi ne pas tout faire avec Postgres Changes ? Moi aussi, au début. Puis sur un tableau blanc collaboratif, chaque mouvement de curseur écrivait en base — le CPU PostgreSQL a grimpé à 90 %. Certaines données n’ont tout simplement pas besoin d’être persistées.
Règle simple : historique requis → Postgres Changes ; état courant uniquement → Presence ; haute fréquence et éphémère → Broadcast.
Postgres Changes : écouter les changements en base
Le scénario le plus courant : réagir aux INSERT, UPDATE, DELETE — nouveau message, commande mise à jour, like. Supabase Realtime s’appuie sur la réplication logique PostgreSQL : chaque modification est captée et poussée aux clients abonnés.
Activer l’écoute Realtime
Côté base, activez la publication dans le SQL Editor Supabase :
-- Activer la publication Realtime
ALTER publication supabase_realtime ADD TABLE messages;
-- Pour écouter UPDATE et DELETE, REPLICA IDENTITY FULL est requis
ALTER TABLE messages REPLICA IDENTITY FULL;
Pourquoi REPLICA IDENTITY FULL ? Par défaut, PostgreSQL ne journalise que la clé primaire de la ligne modifiée. Pour obtenir l’avant/après complet (audit, par exemple), activez cette option — au prix d’un volume d’écriture plus élevé. Réservez-la aux tables qui en ont vraiment besoin.
Code client d’abonnement
Exemple avec des messages de chat :
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface Message {
id: string
content: string
user_id: string
created_at: string
}
export function useRealtimeMessages(roomId: string) {
const [messages, setMessages] = useState<Message[]>([])
useEffect(() => {
// Charger l'historique d'abord
const fetchMessages = async () => {
const { data } = await supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
fetchMessages()
// S'abonner aux nouveaux messages
const channel = supabase
.channel(`messages:${roomId}`)
.on(
'postgres_changes',
{
event: 'INSERT', // écouter uniquement les insertions
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}` // filtrer par salon
},
(payload) => {
// payload.new contient la ligne insérée
setMessages(prev => [...prev, payload.new as Message])
}
)
.subscribe()
// Nettoyer l'abonnement
return () => {
supabase.removeChannel(channel)
}
}, [roomId])
return messages
}
Points d’attention :
- Historique puis incrémental : à l’entrée dans le salon, affichez l’historique avant d’écouter les nouveautés — piège fréquent.
- Paramètre filter : filtrez par colonne (
champ=eq.valeur) pour éviter le bruit. - Nettoyage : au démontage du composant,
removeChannel— sinon fuite mémoire.
Configuration RLS (important)
Souvent négligé, pourtant critique en production. Realtime respecte le Row Level Security de la table. Sans RLS bien configuré, le client ne reçoit rien — ou reçoit trop.
Exemple de schéma de salon :
-- Table messages
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id),
user_id uuid REFERENCES auth.users(id),
content text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Activer RLS
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Voir les messages des salons dont on est membre
CREATE POLICY "Users can view messages in their rooms"
ON messages FOR SELECT
USING (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
-- Seuls les membres peuvent insérer
CREATE POLICY "Room members can insert messages"
ON messages FOR INSERT
WITH CHECK (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
L’abonnement Realtime applique ces policies : l’utilisateur ne voit que les changements autorisés. Ne comptez pas sur un filtre front — ce n’est pas sécurisé.
Si rien n’arrive, vérifiez :
- La table est dans la publication
supabase_realtime - Les policies RLS sont correctes
Presence : suivre qui est en ligne
Presence convient à « qui est connecté maintenant » — effectif du salon, qui édite quelle zone, qui tape. Pas besoin de base : la mémoire suffit.
Principe
Chaque client rejoint un channel et appelle track() pour publier son état. Le service Realtime maintient un snapshot ; join, leave et mises à jour sont propagés à tous les abonnés.
Liste des utilisateurs en ligne
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface UserPresence {
user_id: string
username: string
online_at: string
}
export function useOnlineUsers(roomId: string, currentUser: { id: string; username: string }) {
const [users, setUsers] = useState<UserPresence[]>([])
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`, {
config: {
presence: {
key: currentUser.id // clé = ID utilisateur
}
}
})
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const onlineUsers = Object.values(state).flat() as UserPresence[]
setUsers(onlineUsers)
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
console.log('Utilisateur rejoint :', newPresences)
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
console.log('Utilisateur parti :', leftPresences)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
online_at: new Date().toISOString()
})
}
})
return () => {
supabase.removeChannel(channel)
}
}, [roomId, currentUser])
return users
}
Utilisation dans l’UI :
function ChatRoom({ roomId, currentUser }) {
const onlineUsers = useOnlineUsers(roomId, currentUser)
return (
<div className="flex items-center gap-2 mb-4">
<span className="text-sm text-gray-500">
{onlineUsers.length} en ligne
</span>
<div className="flex -space-x-2">
{onlineUsers.map(user => (
<div
key={user.user_id}
className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm"
title={user.username}
>
{user.username[0]}
</div>
))}
</div>
</div>
)
}
Indicateur de saisie
À la frappe, mettez à jour votre état ; après quelques secondes sans frappe, effacez-le.
export function useTypingIndicator(roomId: string, currentUser: { id: string }) {
const [typingUsers, setTypingUsers] = useState<string[]>([])
const channelRef = useRef<RealtimeChannel | null>(null)
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`)
channelRef.current = channel
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const typing = Object.values(state)
.flat()
.filter((u: any) => u.is_typing && u.user_id !== currentUser.id)
.map((u: any) => u.username)
setTypingUsers(typing)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
is_typing: false
})
}
})
return () => {
supabase.removeChannel(channel)
}
}, [roomId, currentUser])
const setTyping = (isTyping: boolean) => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
channelRef.current?.track({
user_id: currentUser.id,
is_typing
})
if (isTyping) {
typingTimeoutRef.current = setTimeout(() => {
channelRef.current?.track({
user_id: currentUser.id,
is_typing: false
})
}, 3000)
}
}
return { typingUsers, setTyping }
}
Évitez d’appeler track() à chaque touche — debouncez ou auto-réinitialisez après quelques secondes, comme ci-dessus.
Broadcast : curseur et messages instantanés
Le mode le plus léger : pas de stockage, envoi et oubli — idéal pour le haut débit temporaire.
Suivi du curseur
Tableau blanc ou éditeur multi-utilisateurs : afficher la position du curseur. Broadcast convient — pas d’historique de curseur en base.
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState, useRef } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface CursorPosition {
user_id: string
username: string
x: number
y: number
color: string
}
export function useCursors(canvasId: string, currentUser: { id: string; username: string }) {
const [cursors, setCursors] = useState<Record<string, CursorPosition>>({})
const channelRef = useRef<RealtimeChannel | null>(null)
useEffect(() => {
const channel = supabase.channel(`canvas:${canvasId}`)
channelRef.current = channel
channel
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
if (payload.user_id !== currentUser.id) {
setCursors(prev => ({
...prev,
[payload.user_id]: payload
}))
}
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [canvasId, currentUser])
const sendCursor = (x: number, y: number) => {
channelRef.current?.send({
type: 'broadcast',
event: 'cursor',
payload: {
user_id: currentUser.id,
username: currentUser.username,
x,
y,
color: getUserColor(currentUser.id)
}
})
}
return { cursors, sendCursor }
}
function getUserColor(userId: string): string {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4']
const index = userId.charCodeAt(0) % colors.length
return colors[index]
}
Dans le composant :
function CollaborativeCanvas({ canvasId, currentUser }) {
const { cursors, sendCursor } = useCursors(canvasId, currentUser)
const handleMouseMove = (e: React.MouseEvent) => {
sendCursor(e.clientX, e.clientY)
}
return (
<div className="relative w-full h-full" onMouseMove={handleMouseMove}>
{Object.entries(cursors).map(([userId, cursor]) => (
<div
key={userId}
className="absolute pointer-events-none"
style={{ left: cursor.x, top: cursor.y }}
>
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: cursor.color }} />
<span className="text-xs ml-1">{cursor.username}</span>
</div>
))}
</div>
)
}
Broadcast vs Presence
Chevauchement possible, mais :
- Broadcast : envois fréquents (dizaines par seconde) — curseur, position en jeu
- Presence : mises à jour d’état plus rares — en ligne, en train d’écrire
Souvent les deux ensemble : Broadcast pour le mouvement, Presence pour l’état.
Cas pratique : application de chat collaborative
On combine les trois modes :
- Messages temps réel (Postgres Changes)
- Liste des connectés (Presence)
- Indicateur de saisie (Presence)
Schéma base
-- Salons
CREATE TABLE rooms (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Membres
CREATE TABLE room_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id),
joined_at timestamptz DEFAULT now(),
UNIQUE(room_id, user_id)
);
-- Messages
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id),
content text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Realtime
ALTER publication supabase_realtime ADD TABLE messages;
-- RLS
ALTER TABLE messages ENABLE ROW LEVEL Security;
CREATE POLICY "Users can view messages in their rooms"
ON messages FOR SELECT
USING (
room_id IN (
SELECT room_id FROM room_members WHERE user_id = auth.uid()
)
);
CREATE POLICY "Room members can send messages"
ON messages FOR INSERT
WITH CHECK (
room_id IN (
SELECT room_id FROM room_members WHERE user_id = auth.uid()
)
);
Composant React complet
Version simplifiée utilisant les trois modes :
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState, useRef, useCallback } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface Message {
id: string
content: string
user_id: string
username: string
created_at: string
}
interface UserPresence {
user_id: string
username: string
is_typing?: boolean
}
export function CollaborativeChat({ roomId, currentUser }) {
const [messages, setMessages] = useState<Message[]>([])
const [onlineUsers, setOnlineUsers] = useState<UserPresence[]>([])
const [typingUsers, setTypingUsers] = useState<string[]>([])
const [inputValue, setInputValue] = useState('')
const channelRef = useRef<RealtimeChannel | null>(null)
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`, {
config: { presence: { key: currentUser.id } }
})
channelRef.current = channel
channel.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`
},
(payload) => setMessages(prev => [...prev, payload.new as Message])
)
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const users = Object.values(state).flat() as UserPresence[]
setOnlineUsers(users)
const typing = users
.filter(u => u.is_typing && u.user_id !== currentUser.id)
.map(u => u.username)
setTypingUsers(typing)
})
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
is_typing: false
})
const { data } = await supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
})
return () => supabase.removeChannel(channel)
}, [roomId, currentUser])
const sendMessage = async () => {
if (!inputValue.trim()) return
await supabase.from('messages').insert({
room_id: roomId,
user_id: currentUser.id,
content: inputValue.trim()
})
setInputValue('')
setTyping(false)
}
const setTyping = useCallback((isTyping: boolean) => {
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
channelRef.current?.track({
user_id: currentUser.id,
username: currentUser.username,
is_typing
})
if (isTyping) {
typingTimeoutRef.current = setTimeout(() => setTyping(false), 3000)
}
}, [currentUser])
return (
<div className="flex h-full">
<div className="w-64 border-r p-4">
<h3 className="font-bold mb-2">En ligne ({onlineUsers.length})</h3>
{onlineUsers.map(user => (
<div key={user.user_id} className="py-1">
{user.username}
{user.is_typing && <span className="text-sm text-gray-500"> (saisie)</span>}
</div>
))}
</div>
<div className="flex-1 flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
{messages.map(msg => (
<div key={msg.id} className="mb-2">
<span className="font-bold">{msg.username}: </span>
{msg.content}
</div>
))}
{typingUsers.length > 0 && (
<div className="text-gray-500 text-sm">
{typingUsers.join(', ')} est en train d'écrire…
</div>
)}
</div>
<div className="p-4 border-t">
<input
value={inputValue}
onChange={e => { setInputValue(e.target.value); setTyping(true) }}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
placeholder="Saisir un message…"
className="w-full p-2 border rounded"
/>
</div>
</div>
</div>
)
}
Points de conception
- Un seul Channel pour les trois fonctions — moins de connexions.
- Structure Presence :
is_typingdans le même objet presence. - Nettoyage :
removeChannelau démontage.
Bonnes pratiques performance et sécurité
Le code tourne ; avant la mise en production, quelques détails.
Gestion des connexions
Chaque channel consomme des ressources WebSocket. Supabase mutualise une connexion, mais l’abus reste possible.
Recommandations :
- Deux à trois channels maximum par page
- Regrouper les fonctions sur un channel (comme le chat ci-dessus)
removeChanneldès que l’utilisateur quitte la page
useEffect(() => {
const channel = supabase.channel('my-channel')
channel.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [])
Le RLS est obligatoire
Ne vous fiez pas au filtrage front. Realtime applique RLS : l’utilisateur ne reçoit que ce qu’il a le droit de voir.
Si l’abonnement est vide :
- Table dans
supabase_realtimepublication ? - Policies RLS correctes (outil de test du Dashboard) ?
- Utilisateur connecté (
auth.uid()renvoie quoi ?)
Tarification
Realtime est facturé au pic de connexions concurrentes : 10 $ pour 1 000 connexions au pic. Pour la plupart des apps PME, le quota gratuit suffit. À forte audience simultanée, limitez le nombre de channels.
La démo officielle Multiplayer.dev montre les trois modes en action.
Conclusion
Le choix se résume ainsi :
| Historique ? | Haute fréquence ? | Mode recommandé |
|---|---|---|
| Oui | Non | Postgres Changes |
| Non | Non | Presence |
| Non | Oui | Broadcast |
Pour le collaboratif (chat, tableau blanc, édition de document), vous utiliserez souvent les trois : Postgres Changes pour les messages, Presence pour l’en ligne, Broadcast pour le curseur.
Testez d’abord Multiplayer.dev, puis un petit demo de chat — c’est le moyen le plus rapide de monter en compétence.
Prochain article prévu : Supabase Storage, upload et traitement d’images. Des questions sur Realtime ? Laissez un commentaire.
FAQ
Quelle différence entre les trois modes Supabase Realtime ?
• Postgres Changes écoute les changements en base, données persistées — messages de chat, statut de commande
• Presence suit l'état en ligne des utilisateurs, données en mémoire — liste des connectés, indicateur de saisie
• Broadcast diffuse des messages éphémères, sans stockage — suivi du curseur, position en temps réel
Pourquoi l'abonnement ne reçoit aucune donnée ?
1. La table est-elle dans la publication supabase_realtime (ALTER publication supabase_realtime ADD TABLE nom_table) ?
2. Les policies RLS sont-elles correctes (Realtime applique RLS automatiquement) ?
3. L'utilisateur a-t-il le droit de voir les données (contrôlez la valeur renvoyée par auth.uid()) ?
Postgres Changes ou Broadcast : lequel choisir ?
Les données Presence sont-elles persistées ?
Quel impact des abonnements Realtime sur le RLS ?
Combien de channels par page ?
12 min de lecture · Publié le: 15 avr. 2026 · Mis à jour le: 27 juil. 2026
Supabase en pratique
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
Supabase Storage en pratique : upload, contrôle d'accès et accélération CDN
Maîtrisez Supabase Storage de bout en bout : upload de fichiers, configuration des permissions, intégration CDN, politiques RLS, isolation par utilisateur, Smart CDN et transformation d'images.
Partie 4 sur 10
Suivant
Supabase Realtime en pratique : gestion WebSocket et reconnexion
Guide pratique Supabase Realtime : gestion des connexions WebSocket, stratégie de reconnexion et abonnements Postgres Changes. Choix entre Broadcast, Presence et Postgres Changes, plus bonnes pratiques en production.
Partie 6 sur 10



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire