Changer le thème

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

Easton editorial illustration: three-option fit selector

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 :

ModeStockageCas typiquesPersistance
Postgres ChangesPostgreSQLMessages, notifications, commandesOui
PresenceMémoire (service Realtime)Utilisateurs en ligne, indicateur de saisieNon
BroadcastAucun (relai instantané)Curseur, position temps réelNon

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 :

  1. Historique puis incrémental : à l’entrée dans le salon, affichez l’historique avant d’écouter les nouveautés — piège fréquent.
  2. Paramètre filter : filtrez par colonne (champ=eq.valeur) pour éviter le bruit.
  3. 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 :

  1. La table est dans la publication supabase_realtime
  2. 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

  1. Un seul Channel pour les trois fonctions — moins de connexions.
  2. Structure Presence : is_typing dans le même objet presence.
  3. Nettoyage : removeChannel au 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)
  • removeChannel dè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 :

  1. Table dans supabase_realtime publication ?
  2. Policies RLS correctes (outil de test du Dashboard) ?
  3. 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é
OuiNonPostgres Changes
NonNonPresence
NonOuiBroadcast

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 ?
Chaque mode a son rôle :

• 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 ?
Vérifiez trois points :

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 ?
Postgres Changes si vous avez besoin d'historique et de relecture. Broadcast pour des données temporaires à haute fréquence sans persistance. Exemple : messages de chat avec le premier, position du curseur avec le second.
Les données Presence sont-elles persistées ?
Non. Les données Presence vivent en mémoire dans le service Realtime ; elles disparaissent à la déconnexion. Idéal pour l'état en ligne et autres données éphémères.
Quel impact des abonnements Realtime sur le RLS ?
Les abonnements Realtime appliquent automatiquement les policies RLS de la table. L'utilisateur ne reçoit que les changements qu'il est autorisé à voir. C'est un pilier de sécurité en production — ne filtrez pas côté client.
Combien de channels par page ?
Deux à trois maximum. Regroupez les fonctions liées sur un même channel (ex. chat : Postgres Changes + Presence) pour réduire les connexions WebSocket. À la sortie de page, appelez toujours removeChannel.

12 min de lecture · Publié le: 15 avr. 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog