Design wechseln

Supabase Realtime in der Praxis: Drei Modi im Vergleich und kollaborative Apps

Easton editorial illustration: three-option fit selector

Sie starren auf den Bildschirm – „wird getippt …“ blinkt zum siebzehnten Mal, während Sie die Chat-Seite erneut neu laden. Auf der anderen Seite ist Ihr Freund eindeutig online, doch die Nachrichten kommen einfach nicht an. Eine wirklich „Echtzeit“-App zu bauen, ist schwieriger, als man denkt.

Mit WebSockets habe ich viele Fallstricke kennengelernt: Reconnect nach Verbindungsabbruch, Statussynchronisation, Broadcast-Design. Erst mit Supabase Realtime im letzten Jahr wurde mir klar, dass sich vieles davon auslagern lässt. Supabase bietet drei Echtzeit-Modi: Postgres Changes hört auf Datenbankänderungen, Presence verfolgt Nutzerstatus, Broadcast sendet temporäre Nachrichten. Jeder Modus hat seinen Einsatzbereich – die richtige Wahl spart Aufwand, die falsche bereitet Ärger.

In diesem Artikel erklären wir die drei Modi im Detail: wann welcher passt, wie der Code aussieht und wie RLS-Sicherheitsregeln konfiguriert werden. Am Ende kombinieren wir alles in einer vollständigen kollaborativen Chat-App.

Supabase Realtime: Die drei Kernfunktionen im Vergleich

Kurz gesagt: Jeder Modus hat seine Aufgabe – mischen Sie sie nicht wahllos.

Postgres Changes hört auf Datenbankänderungen. Geeignet für Chat-Nachrichten, Benachrichtigungen, Bestellstatus – alles, was persistiert werden muss. Daten liegen in der Datenbank, Clients abonnieren nur die Änderungen.

Presence verfolgt den Online-Status. Geeignet für „Wer ist online?“, Tipp-Indikatoren, Cursor-Positionen in kollaborativen Editoren. Daten werden nicht in der DB gespeichert, sondern im Speicher – nach dem Disconnect sind sie weg.

Broadcast sendet temporäre Nachrichten. Geeignet für Cursor-Bewegungen auf dem Canvas, Live-Positionen in Spielen, temporäre Operationssynchronisation. Unterschied zu Presence: Broadcast eignet sich für hochfrequentes Senden, Presence für Statussynchronisation.

Eine Übersicht:

ModusDatenspeicherTypische SzenarienPersistenz
Postgres ChangesPostgreSQLChat-Nachrichten, Benachrichtigungen, BestellstatusJa
PresenceSpeicher (Realtime-Dienst)Online-Nutzer, Tipp-IndikatorenNein
BroadcastKeine Speicherung (sofortige Weiterleitung)Cursor-Bewegungen, Live-PositionenNein

Vielleicht fragen Sie sich: Warum nicht einfach alles über Postgres Changes? Ehrlich gesagt dachte ich am Anfang genauso. Bei einer kollaborativen Whiteboard-App schrieb jede Cursor-Bewegung in die Datenbank – die CPU-Last stieg auf 90 %. Dann wurde mir klar: Manche Daten müssen gar nicht persistiert werden.

Merken Sie sich diese Regel: Verlauf nötig → Postgres Changes. Nur aktueller Status → Presence. Hochfrequent und temporär → Broadcast.

Postgres Changes: Datenbankänderungen abhören

Der häufigste Anwendungsfall: Änderungen in der Datenbank verfolgen. Neue Chat-Nachrichten, geänderte Bestellstatus, neue Likes – alles, was dauerhaft gespeichert werden soll.

Supabase Realtime nutzt PostgreSQL logical replication. Vereinfacht gesagt: Bei jedem INSERT, UPDATE oder DELETE erfasst der Realtime-Dienst die Änderung und pusht sie an abonnierende Clients.

Realtime-Abhörung aktivieren

Zuerst Publication auf Datenbankseite einrichten. Im Supabase SQL Editor ausführen:

-- Realtime publication aktivieren
ALTER publication supabase_realtime ADD TABLE messages;

-- Für Tabellen mit UPDATE- und DELETE-Abhörung REPLICA IDENTITY FULL setzen
ALTER TABLE messages REPLICA IDENTITY FULL;

Warum REPLICA IDENTITY FULL? Standardmäßig protokolliert PostgreSQL nur den Primärschlüssel geänderter Zeilen. Brauchen Sie vollständige Vorher-/Nachher-Daten (z. B. für Audit-Logs), muss diese Option aktiv sein. Achtung: Das erhöht den Schreibaufwand – nur für Tabellen aktivieren, die es wirklich brauchen.

Client-Abonnement-Code

Client-Code am Beispiel von Chat-Nachrichten:

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(() => {
    // Zuerst Verlauf laden
    const fetchMessages = async () => {
      const { data } = await supabase
        .from('messages')
        .select('*')
        .eq('room_id', roomId)
        .order('created_at', { ascending: true })

      if (data) setMessages(data)
    }

    fetchMessages()

    // Neue Nachrichten abonnieren
    const channel = supabase
      .channel(`messages:${roomId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',      // Nur Inserts abhören
          schema: 'public',
          table: 'messages',
          filter: `room_id=eq.${roomId}`  // Auf bestimmten Raum filtern
        },
        (payload) => {
          // payload.new enthält die neu eingefügten Daten
          setMessages(prev => [...prev, payload.new as Message])
        }
      )
      .subscribe()

    // Abonnement aufräumen
    return () => {
      supabase.removeChannel(channel)
    }
  }, [roomId])

  return messages
}

Einige Details:

  1. Erst Verlauf, dann Inkrement: Beim Betreten eines Chat-Raums zuerst alte Nachrichten anzeigen, dann neue empfangen. Häufiger Fehler: nur abonnieren, ohne Verlauf zu laden.
  2. filter-Parameter: Nach Datenbankfeld filtern, irrelevante Nachrichten vermeiden. Syntax: feldname=eq.wert.
  3. Abonnement aufräumen: Beim Unmount removeChannel aufrufen – sonst Memory Leak.

RLS-Sicherheitskonfiguration (wichtig!)

Viele übersehen diesen Punkt – er ist aber entscheidend für Produktions-Apps. Standardmäßig folgt Realtime den RLS-Regeln (Row Level Security) der Tabelle. Ohne RLS empfängt der Client möglicherweise nichts – oder Daten, die er nicht sehen sollte.

Beispiel für eine Chat-Raum-Tabelle:

-- Nachrichtentabelle
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()
);

-- RLS aktivieren
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

-- Nachrichten in eigenen Räumen anzeigen
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()
  )
);

-- Raummitglieder dürfen Nachrichten senden
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()
  )
);

Realtime-Abonnements wenden diese Regeln automatisch an. Nutzer erhalten nur Nachrichtenänderungen, die sie sehen dürfen. Wichtig: Nicht im Frontend filtern – das ist unsicher.

Kommen keine Daten an, zwei Dinge prüfen:

  1. Ist die Tabelle in der supabase_realtime-Publication?
  2. Sind RLS-Regeln korrekt konfiguriert?

Presence: Online-Status verfolgen

Presence eignet sich für „Wer ist gerade online?“ – Online-Anzahl im Chat, wer welchen Abschnitt bearbeitet, wer tippt gerade. Diese Daten müssen nicht in der DB liegen, Speicher reicht.

Grundprinzip

Presence funktioniert so: Jeder Client tritt einem Channel bei und ruft track() auf, um seinen Status zu registrieren. Der Realtime-Dienst hält einen Snapshot aller Client-Status und benachrichtigt alle Abonnenten bei Join, Leave oder Update.

Online-Nutzerliste implementieren

Code für eine Online-Nutzerliste im Chat-Raum:

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  // Nutzer-ID als Key
        }
      }
    })

    channel
      .on('presence', { event: 'sync' }, () => {
        // Bei Sync alle Online-Nutzer abrufen
        const state = channel.presenceState()
        // presenceState() liefert { [key]: [UserPresence, ...] }
        const onlineUsers = Object.values(state).flat() as UserPresence[]
        setUsers(onlineUsers)
      })
      .on('presence', { event: 'join' }, ({ newPresences }) => {
        // Neuer Nutzer beigetreten
        console.log('Nutzer beigetreten:', newPresences)
      })
      .on('presence', { event: 'leave' }, ({ leftPresences }) => {
        // Nutzer verlassen
        console.log('Nutzer verlassen:', leftPresences)
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          // Nach erfolgreichem Abonnement eigenen Status registrieren
          await channel.track({
            user_id: currentUser.id,
            username: currentUser.username,
            online_at: new Date().toISOString()
          })
        }
      })

    return () => {
      supabase.removeChannel(channel)
    }
  }, [roomId, currentUser])

  return users
}

Verwendung:

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

Tipp-Indikator

Presence eignet sich auch für „wird getippt …“. Beim Tippen den eigenen Status aktualisieren, nach kurzer Pause wieder zurücksetzen.

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

  // Aufrufen, wenn der Nutzer tippt
  const setTyping = (isTyping: boolean) => {
    if (typingTimeoutRef.current) {
      clearTimeout(typingTimeoutRef.current)
    }

    channelRef.current?.track({
      user_id: currentUser.id,
      is_typing
    })

    // Tipp-Status nach 3 Sekunden automatisch löschen
    if (isTyping) {
      typingTimeoutRef.current = setTimeout(() => {
        channelRef.current?.track({
          user_id: currentUser.id,
          is_typing: false
        })
      }, 3000)
    }
  }

  return { typingUsers, setTyping }
}

Kleiner Tipp: Nicht bei jedem Tastendruck track() aufrufen – zu häufig verursacht Probleme. Debouncing nutzen oder wie oben den Status nach ein paar Sekunden automatisch zurücksetzen.

Broadcast: Cursor-Tracking und Sofortnachrichten

Broadcast ist der „leichteste“ der drei Modi – Nachrichten werden nicht gespeichert, nicht persistiert, einfach gesendet und vergessen. Ideal für hochfrequente, temporäre Datenübertragung.

Cursor-Tracking-Beispiel

Kollaborative Whiteboards und Multiplayer-Editoren müssen Cursor-Positionen live anzeigen. Broadcast passt perfekt – Cursor-Positionen brauchen keine DB und keinen Verlauf.

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 }) => {
        // Cursor-Position anderer Nutzer empfangen
        if (payload.user_id !== currentUser.id) {
          setCursors(prev => ({
            ...prev,
            [payload.user_id]: payload
          }))
        }
      })
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [canvasId, currentUser])

  // Eigene Cursor-Position senden
  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 }
}

// Farbe aus Nutzer-ID generieren
function getUserColor(userId: string): string {
  const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4']
  const index = userId.charCodeAt(0) % colors.length
  return colors[index]
}

In der Komponente:

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}>
      {/* Cursor anderer Nutzer anzeigen */}
      {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

Broadcast und Presence überschneiden sich teilweise. Der Unterschied:

  • Broadcast für hochfrequentes Senden (Dutzende pro Sekunde) – Cursor-Bewegungen, Positions-Sync in Spielen
  • Presence für Statussynchronisation (gelegentliche Updates) – „Wer ist online?“, „wird getippt …“

Am besten kombinieren. Broadcast für „bewegliche“ Daten, Presence für „Status“-Daten.

Praxisbeispiel: Kollaborative Chat-App bauen

Jetzt kombinieren wir alle drei Modi in einer echten kollaborativen Chat-App:

  • Echtzeit-Nachrichten (Postgres Changes)
  • Online-Nutzerliste (Presence)
  • Tipp-Indikator (Presence)

Datenbank vorbereiten

Tabellen anlegen:

-- Raumtabelle
CREATE TABLE rooms (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,
  created_at timestamptz DEFAULT now()
);

-- Raummitglieder-Tabelle
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)
);

-- Nachrichtentabelle
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 aktivieren
ALTER publication supabase_realtime ADD TABLE messages;

-- RLS aktivieren
ALTER TABLE messages ENABLE ROW LEVEL Security;

-- RLS: Nur Nachrichten in eigenen Räumen sehen
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()
  )
);

-- RLS: Nur Raummitglieder dürfen Nachrichten senden
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()
  )
);

Vollständige React-Komponente

Vereinfachte kollaborative Chat-Komponente mit allen drei Modi:

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(() => {
    // Ein Channel für alle drei Funktionen
    const channel = supabase.channel(`room:${roomId}`, {
      config: { presence: { key: currentUser.id } }
    })
    channelRef.current = channel

    // 1. Postgres Changes: Neue Nachrichten abhören
    channel.on(
      'postgres_changes',
      {
        event: 'INSERT',
        schema: 'public',
        table: 'messages',
        filter: `room_id=eq.${roomId}`
      },
      (payload) => setMessages(prev => [...prev, payload.new as Message])
    )

    // 2. Presence: Online-Nutzer und Tipp-Status
    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)
    })

    // Abonnieren und Presence registrieren
    channel.subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        await channel.track({
          user_id: currentUser.id,
          username: currentUser.username,
          is_typing: false
        })

        // Nachrichtenverlauf laden
        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">
      {/* Links: Online-Nutzer */}
      <div className="w-64 border-r p-4">
        <h3 className="font-bold mb-2">Online ({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"> (tippt)</span>}
          </div>
        ))}
      </div>

      {/* Rechts: Chat */}
      <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(', ')} tippt
            </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="Nachricht eingeben …"
            className="w-full p-2 border rounded"
          />
        </div>
      </div>
    </div>
  )
}

Wichtige Design-Entscheidungen

  1. Ein Channel für alles: Alle drei Funktionen teilen sich einen Channel – weniger Verbindungen.
  2. Presence-Datenstruktur: is_typing im gleichen Presence-Objekt.
  3. Aufräumen: Beim Unmount unbedingt removeChannel aufrufen.

Performance- und Sicherheits-Best Practices

Der Code läuft – vor dem Go-live bleiben noch ein paar Details.

Verbindungsmanagement

Jeder Channel belegt eine WebSocket-Verbindung. Supabase teilt zwar mehrere Channels über eine Verbindung, aber Missbrauch schadet trotzdem.

Empfehlungen:

  • Maximal 2–3 Channels pro Seite
  • Verwandte Funktionen in einem Channel bündeln (wie im Chat-Beispiel)
  • Beim Verlassen sofort removeChannel aufrufen
// Aufräum-Beispiel
useEffect(() => {
  const channel = supabase.channel('my-channel')
  channel.subscribe()

  return () => {
    // Nicht nur unsubscribe – removeChannel verwenden
    supabase.removeChannel(channel)
  }
}, [])

RLS ist Pflicht

„Frontend-Filter reicht“ funktioniert nicht. Realtime-Abonnements wenden RLS automatisch an – Nutzer erhalten nur Änderungen, die sie sehen dürfen.

Kommen keine Daten an, in dieser Reihenfolge prüfen:

  1. Ist die Tabelle in der supabase_realtime-Publication?
  2. Sind RLS-Regeln korrekt? (RLS-Testtool im Supabase Dashboard)
  3. Ist der Nutzer angemeldet? (Was liefert auth.uid()?)

Pricing beachten

Supabase Realtime berechnet nach gleichzeitigen Verbindungen: 10 $ pro 1.000 Spitzenverbindungen. Für die meisten kleinen und mittleren Apps reicht das kostenlose Kontingent. Bei vielen gleichzeitig online Nutzern die Channel-Anzahl im Blick behalten.

Offizielles Demo-Projekt: Multiplayer.dev – dort sehen Sie alle drei Modi in Aktion.

Fazit

Die Moduswahl ist einfach:

Verlauf nötig?Hochfrequent?Empfohlener Modus
JaNeinPostgres Changes
NeinNeinPresence
NeinJaBroadcast

Bei kollaborativen Apps (Chat, Whiteboard, Dokumenten-Editor) brauchen Sie vermutlich alle drei: Postgres Changes für Nachrichten, Presence für Online-Status, Broadcast für Cursor.

Probieren Sie zuerst Multiplayer.dev aus, um die Wirkung live zu sehen. Dann bauen Sie ein kleines Chat-Demo – der schnellste Einstieg.

Als Nächstes folgt Supabase Storage: Datei-Upload und Bildverarbeitung. Fragen zu Realtime? Hinterlassen Sie gerne einen Kommentar.

FAQ

Was ist der Unterschied zwischen den drei Supabase-Realtime-Modi?
Jeder Modus hat seine Aufgabe:

• Postgres Changes hört auf Datenbankänderungen, Daten werden persistiert – ideal für Chat-Nachrichten und Bestellstatus
• Presence verfolgt den Online-Status, Daten liegen im Speicher – ideal für Online-Listen und Tipp-Indikatoren
• Broadcast sendet temporäre Nachrichten ohne Speicherung – ideal für Cursor-Tracking und Live-Positionen
Warum kommen bei meinem Abonnement keine Daten an?
Drei Punkte prüfen:

1. Ist die Tabelle in der supabase_realtime-Publication? (ALTER publication supabase_realtime ADD TABLE tabellenname)
2. Sind RLS-Regeln korrekt konfiguriert? (Realtime-Abonnements wenden RLS automatisch an)
3. Hat der Nutzer Berechtigung, die Daten zu sehen? (auth.uid()-Rückgabewert prüfen)
Postgres Changes oder Broadcast – was wählen?
Brauchen Sie Verlauf und Nachvollziehbarkeit, wählen Sie Postgres Changes. Für hochfrequente temporäre Daten ohne Persistenz wählen Sie Broadcast. Chat-Nachrichten eignen sich für Ersteres, Cursor-Positionen für Letzteres.
Werden Presence-Daten persistiert?
Nein. Presence-Daten liegen im Speicher des Realtime-Dienstes und verschwinden, sobald ein Nutzer die Verbindung trennt. Ideal für temporäre Statusinformationen wie Online-Anwesenheit.
Welche Auswirkungen hat RLS auf Realtime-Abonnements?
Realtime-Abonnements wenden automatisch die RLS-Regeln der Tabelle an. Nutzer erhalten nur Änderungen, die sie sehen dürfen. Das ist ein Sicherheitskern in Produktions-Apps – filtern Sie nicht im Frontend.
Wie viele Channels sollte eine Seite nutzen?
Empfehlung: maximal 2–3. Verwandte Funktionen in einem Channel bündeln (z. B. Chat mit Postgres Changes + Presence), um WebSocket-Verbindungen zu sparen. Beim Verlassen der Seite unbedingt removeChannel aufrufen.

12 Min. Lesezeit · Veröffentlicht am: 15. Apr. 2026 · Aktualisiert am: 14. Juli 2026

Ähnliche Beiträge

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog