Supabase Realtime: Building Real-Time Applications with PostgreSQL Changes
How Supabase Realtime streams Postgres changes, broadcasts and presence over one WebSocket, with setup, channel patterns and scaling notes.

Supabase Realtime
Building Real-Time Applications with PostgreSQL Changes
Introduction
Real-time features used to mean standing up a separate WebSocket server, a message broker, and glue code just to tell your frontend “hey, something changed.” Supabase Realtime streams database events, custom broadcasts, and user presence over one WebSocket connection, so teams already on Supabase for auth and Postgres can skip a second real-time stack.
You will work through Postgres Changes, Broadcast, and Presence in a JavaScript app, enable Realtime on a table, subscribe to live INSERT/UPDATE/DELETE events, send ephemeral messages between clients, track online users, and avoid the errors that show up most often in production.
Quick Answer
Supabase Realtime lets you subscribe to live PostgreSQL changes (inserts, updates, deletes), broadcast low-latency messages between clients, and track who is online, all through one WebSocket connection. It runs on Elixir/Phoenix Channels and reads Postgres logical replication (WAL) for database events, so you do not need polling or a separate message queue to keep your UI in sync with your data.
What Is Supabase Realtime?
Supabase Realtime is a WebSocket service built on Phoenix Channels. It sits on top of your Postgres database and gives you three ways to push live updates to connected clients, all through the same @supabase/supabase-js client and channel API.
Most teams reach for Realtime when they want a UI that reflects database writes without polling, fast ephemeral messaging between browsers, or a shared view of who is online in a room. You pick the feature that matches the data. Persisted state belongs in Postgres Changes. Fleeting UI state belongs in Broadcast or Presence.
Postgres Changes
Postgres Changes listens to your database's write-ahead log (WAL) through logical replication. When a row is inserted, updated, or deleted on a table you've added to the supabase_realtime publication, Realtime picks up the committed change and pushes it to every client subscribed to that table.
The payload includes the event type and the new or old row data, so your frontend can update local state without refetching the whole table. Realtime also runs each event through your Row Level Security policies before delivery. If a user cannot SELECT a row, they will not receive its change event either. That makes it a good fit for live comment feeds, order status dashboards, inventory counters, and admin panels that reflect writes from other users.
Your app / SQL editor
↓
INSERT, UPDATE, or DELETE on a watched table
↓
Change committed to Postgres WAL
↓
Supabase Realtime reads replication stream
↓
RLS check for each subscribed client
↓
Matching clients receive payload.new / payload.old
Broadcast
Typing indicators are the classic Broadcast example. User A starts typing, your app fires a cursor-move or typing event on channel "room-42", and User B's client receives it without a database write. Broadcast is pub/sub over a shared channel name: one client sends a message, Realtime relays it to every other client on that channel. That keeps latency low and keeps cursor coordinates and “user is typing” flags out of Postgres.
Every client must join the same channel and finish subscribing before sending. Messages sent before the channel is joined are dropped, so wait for a SUBSCRIBED status in your callback. The same pattern works for live cursors, game moves, and custom notifications that do not need to survive a page refresh.
Client A: roomChannel.send({ event: "cursor-move", ... })
↓
Supabase Realtime channel ("room-42")
↓
Server relays to all joined clients
↓
Client B and Client C receive the event (no database write)
Presence
Presence tracks who is connected to a channel and syncs a small state object across all clients. Each client calls .track() with metadata like online_at or a display name. When someone joins or leaves, every client on the channel gets a sync event and can read the full state through presenceState(). Chat apps use this for online user lists; collaborative docs use it for “3 people viewing this doc” indicators; multiplayer sessions use it for room occupancy.
Presence uses a CRDT so concurrent joins and disconnects reconcile cleanly across nodes. If the WebSocket reconnects after a network drop, call .track() again inside your SUBSCRIBED handler or that user's online status disappears until they do.
Client A joins channel and calls .track({ online_at: ... })
↓
Supabase Realtime Presence (CRDT sync)
↓
All clients on the channel receive a presence sync event
↓
Each client reads presenceState() to render who's online
Use Postgres Changes when the UI must reflect committed database state. Use Broadcast when the update is temporary and does not belong in a table. Use Presence when you need a live list of connected users without querying Postgres on an interval.
The Problem
Keeping a UI in sync with server-side data usually means polling every few seconds (wasteful and laggy) or building a custom WebSocket server that listens for database triggers, manages connections, handles reconnection, and scales on its own. Add online-user tracking or cursor sync between collaborators, and you are maintaining two or three real-time systems. A three-person team should not need Redis and Socket.io just to show a “new comment” badge on a messages table.
The Solution
Supabase Realtime reads Postgres's replication stream and exposes channels over WebSockets. See the official Realtime documentation for configuration details and the Realtime source on GitHub for implementation internals.
Client (browser/mobile)
↓
Supabase Realtime (Phoenix Channels)
↓
Postgres WAL (logical replication)
↓
PostgreSQL Database
Postgres Changes reads the WAL instead of polling tables, so committed writes reach subscribed clients quickly. Broadcast and Presence skip the database and route messages between clients through the Realtime server, which keeps cursor tracking and typing indicators fast.
Prerequisites
- A Supabase project, the free tier is enough to follow along
- Node.js and a JavaScript or TypeScript app (React, Next.js, or plain JS)
- @supabase/supabase-js installed in your project
- Comfortable with basic PostgreSQL and SQL. Enough to enable a table for replication.
- RLS basics in place: Realtime follows the same policies you set up for Supabase authentication
Step-by-Step Implementation
Step 1: Install the Supabase Client
npm install @supabase/supabase-js
Step 2: Configure Environment Variables
Create a .env.local file (or equivalent) with your project credentials from the Supabase dashboard:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Initialize the client once and reuse it across your app:
// lib/supabaseClient.js
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
Step 3: Enable Realtime on Your Table
Realtime does not listen to every table by default. Add the table to the supabase_realtime publication from the dashboard (Database → Replication) or via SQL:
alter publication supabase_realtime add table messages;
Step 4: Subscribe to Postgres Changes
Core pattern for database events:
const channel = supabase
.channel("messages-changes")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "messages" },
(payload) => {
console.log("New message:", payload.new);
setMessages((prev) => [...prev, payload.new]);
}
)
.subscribe();
Listen for UPDATE, DELETE, or * (all events). Filter rows with the filter option:
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "messages",
filter: "room_id=eq.42",
},
(payload) => console.log("Updated:", payload.new)
)
End-to-end example: live comment feed
Load existing rows, then subscribe to all change events on the table:
const { data: initialMessages } = await supabase
.from("messages")
.select("*")
.order("created_at", { ascending: true });
setMessages(initialMessages);
supabase
.channel("public:messages")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "messages" },
(payload) => {
if (payload.eventType === "INSERT") {
setMessages((prev) => [...prev, payload.new]);
}
if (payload.eventType === "DELETE") {
setMessages((prev) => prev.filter((m) => m.id !== payload.old.id));
}
}
)
.subscribe();
Step 5: Use Broadcast for Low-Latency Messaging
Use Broadcast when the data does not need to persist, like cursor positions or typing indicators:
const roomChannel = supabase.channel("room-1");
roomChannel
.on("broadcast", { event: "cursor-move" }, (payload) => {
updateCursor(payload.payload);
})
.subscribe();
// Sending a broadcast message
roomChannel.send({
type: "broadcast",
event: "cursor-move",
payload: { x: 120, y: 340, userId: "user-1" },
});
Step 6: Track Online Users with Presence
Presence syncs a shared state object across every client on a channel:
const presenceChannel = supabase.channel("online-users", {
config: { presence: { key: userId } },
});
presenceChannel
.on("presence", { event: "sync" }, () => {
const state = presenceChannel.presenceState();
setOnlineUsers(Object.keys(state));
})
.subscribe(async (status) => {
if (status === "SUBSCRIBED") {
await presenceChannel.track({ online_at: new Date().toISOString() });
}
});
Step 7: Clean Up Subscriptions
Unsubscribe when a component unmounts to avoid memory leaks and duplicate listeners:
useEffect(() => {
const channel = supabase
.channel("messages-changes")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "messages" },
(payload) => {
setMessages((prev) => [...prev, payload.new]);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
Step 8: Test the Implementation
Verify each feature before shipping. Insert a row into messages from the SQL editor and confirm the client logs the payload without a refresh. Open two browser tabs on the same channel and send a cursor event from one to test Broadcast. Open two tabs with different user IDs and confirm both show up in presenceState() after each client calls .track(). Navigate away from the page and make sure the channel is removed so you do not get duplicate events on return.
Check the browser Network tab for a WebSocket connection to your project's Realtime endpoint and watch for SUBSCRIBED status in your channel callback. A successful INSERT should log a payload similar to:
{
"eventType": "INSERT",
"new": { "id": 1, "body": "Hello", "room_id": 42, "created_at": "2026-01-15T10:00:00Z" },
"old": {},
"schema": "public",
"table": "messages"
}
Common Problems / Errors
Postgres Changes Events Aren't Firing
Possible causes:
- The table is not in the supabase_realtime publication
- RLS is on but no SELECT policy lets the subscribing user read the row
- The client passes the wrong schema in .on()
- Realtime is toggled off in dashboard settings
Add the table to the publication, confirm SELECT policies for the authenticated role, and match the schema name exactly.
Presence State Resets Unexpectedly
Usually caused by the channel reconnecting after a network drop without re-calling .track(). Re-track inside the SUBSCRIBED status handler so presence re-syncs after reconnection.
Duplicate Events or Memory Leaks
Happens when a component re-subscribes on every render without cleanup. Call supabase.removeChannel() in a useEffect cleanup function.
Broadcast Messages Not Received
All clients must use the exact same channel name, and .subscribe() must finish before .send(). Messages sent before the channel joins are dropped.
RLS Blocking Realtime Payloads
Realtime respects RLS. An UPDATE that changes a row's visibility (e.g. room_id) may not deliver the full new row to users who no longer have SELECT access.
Best Practices
Keep RLS enabled on any table you subscribe to with postgres_changes, and use channel filters to cut noise server-side instead of filtering every event in the client. Call removeChannel() on unmount, use Broadcast for ephemeral data like cursors and typing indicators, and batch presence updates rather than calling .track() on every mouse move. In production, watch concurrent connection counts on your plan and use one channel per room instead of one global channel for the whole app.
Performance / Security Considerations
Postgres Changes load grows with subscriber count and write volume on watched tables. On a busy table, narrow filters beat broad event: "*" subscriptions. Realtime checks every Postgres Changes event against the subscribing user's RLS policies before delivery, which adds overhead but stops unauthorized rows from leaking. Broadcast and Presence skip the database, so latency stays low, but they do not enforce RLS. Add your own authorization if you broadcast sensitive data. Prefer dedicated channels over one large shared channel so reconnection and cleanup stay cheap.
Alternatives / Comparison
| Feature | Supabase Realtime | Firebase Realtime Database | Pusher |
|---|---|---|---|
| Data source | PostgreSQL WAL | Proprietary NoSQL store | N/A (message-based) |
| Database changes | Yes | Yes | No |
| Presence | Yes | Yes | Yes |
| Broadcast/pub-sub | Yes | Limited | Yes |
| Open source | Yes | No | No |
| Works with existing SQL schema | Yes | No | N/A |
When Should You Use It?
Supabase Realtime fits when you already run Supabase/Postgres and want live UI updates from WAL events without new infrastructure—for example, a chat app that persists messages through Postgres Changes and sends typing indicators through Broadcast on the same channel. Kafka or a custom event pipeline makes more sense when you need millions of events per second and your real-time traffic has no relationship to your relational schema.
FAQ
Does Supabase Realtime work with Row Level Security?
Yes. Postgres Changes events are filtered per user from your RLS policies. A user only gets events for rows they can SELECT.
Can I use Supabase Realtime without Postgres Changes?
Yes. You can use Broadcast and Presence through the same supabase-js channel API without enabling Postgres Changes or adding a table to the supabase_realtime publication.
How many concurrent connections does Supabase Realtime support?
The Free plan includes 200 peak concurrent connections; Pro includes 500, then $10 per 1,000 beyond that. See Supabase Realtime pricing for current quotas and overage rates.
Is Supabase Realtime suitable for production chat applications?
Yes, with RLS, indexed queries on underlying tables, and client-side channel cleanup. Supabase documents production Realtime usage in their official guides.
Why am I not receiving Postgres Changes events even though RLS is enabled?
Usually a missing SELECT policy for the subscribing role, or the table is not in the supabase_realtime publication.
Conclusion
If you already use Supabase for auth and Postgres, Realtime lets you add live updates without a second WebSocket stack. Start with Postgres Changes for persisted data, add Broadcast for typing indicators or cursors, and use Presence when you need to show who is online. Enable the table in the supabase_realtime publication, confirm RLS policies before production, and call removeChannel() on unmount. A small team can ship a live comment feed or room chat without Redis, Socket.io, or a custom message broker.
If your UI is outgrowing polling and manual refreshes, or you're designing live features for chat, dashboards, collaborative editing, or multiplayer sessions, the decisions around Postgres Changes, Broadcast, Presence, and RLS matter more than adding another WebSocket service.
Suggested External Links
- Supabase Realtime official documentation
- Supabase Realtime GitHub repository

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.


