Most Supabase Realtime failures have one of five causes, in rough order of likelihood: the table is not in the supabase_realtime publication, a channel is never removed, a backgrounded tab dropped the socket, RLS runs once per subscriber per event, or you are at a plan connection or message limit. Find your symptom in the router below. The first row that matches is usually the whole answer.
Under those five sit two kinds of failure that get reported with the same words. One is delivery: the table is not published, replication or Realtime API responses are lagging, or the socket has dropped. The other is application design: too many subscribers, expensive authorization, oversized payloads, or channels that are never removed. A slow update can come from either side, so treat the dashboard as evidence for the diagnosis rather than proof that Supabase or your code is at fault.
| What you see | Most likely cause | Where to look |
|---|---|---|
| No updates at all, ever | The table was never added to the supabase_realtime publication | Check the publication first |
| Updates work, then stop after minutes or hours | A backgrounded tab silently dropped the socket and nothing reconnected | The backgrounded tab |
| Updates arrive seconds late | Per-subscriber RLS cost, payload size, or replication lag | Baselines and measurement |
| Every update fires twice | React StrictMode subscribes twice in development | Duplicate updates |
| Works locally, breaks in production | The publication and the policies are per environment | Check the publication first |
| Works fine, then dies once traffic arrives | Concurrent connection or message-rate quota on your plan | Connection limits by plan |
| Subscription in a Next.js page does nothing at all | It was written inside a server component | Server components |
Supabase realtime not working? Check the publication first
Realtime is off per table by default. Postgres Changes only sees a table once that table is in the supabase_realtime publication, so an app with correct client code can receive nothing at all and throw no error. Supabase’s Postgres Changes documentation points at the toggle: your project’s Publications settings, under supabase_realtime. The SQL version is one line.
-- Add the table to the Realtime publication.
alter publication supabase_realtime add table public.messages;
If events do fire but the payload has no previous values, that is the second half of the same setting. A change event carries only the new record by default. replica identity full adds the old record to update and delete payloads.
-- Include previous values in update and delete payloads.
alter table public.messages replica identity full;
Two caveats from the same page. RLS policies are not applied to delete statements, and when RLS is enabled alongside replica identity full, deleted records arrive containing only their primary key. Delete events also cannot be filtered at the subscription level.
The publication is a database object, which makes it the usual reason an app works locally and breaks in production. A table toggled on in one project’s dashboard is not toggled on in another project, and neither is the policy that authorizes the read.
How fast should Supabase Realtime be?
Before tuning anything, decide whether the delay is abnormal. Supabase publishes benchmark numbers for each Realtime feature:
| Feature | Concurrent users | Throughput | Median | p95 | p99 |
|---|---|---|---|---|---|
| Broadcast over WebSockets | 32,000 | 224,000 msgs/sec | 6 ms | 28 ms | 213 ms |
| Broadcast from database | 80,000 | 10,000 msgs/sec | 46 ms | 132 ms | 159 ms |
| Realtime Authorization | 50,000 | over 150,000 msgs/sec | 19 ms | 49 ms | 96 ms |
Postgres Changes gets a grid instead of a single figure, because its throughput depends on your database tier, the number of connected clients, and whether RLS is on. The same page reports 64 changes per second at 500 connected clients without RLS, dropping to 5 changes per second at 3,000 clients with RLS enabled, and total message ceilings between 3,000 and 50,000 per second across tiers. It also states the failure mode directly: if your database cannot authorize the changes rapidly enough, the changes are delayed until you receive a timeout.
Those rows are separate official test profiles, not one latency promise for every Realtime feature or Postgres Changes subscription. Compare an app only with a benchmark that uses the same feature and materially similar clients, authorization, database tier, payload, region, and workload. Treat a delay around one second as a symptom to investigate, not as a universal boundary between tuning and failure.
How to measure the actual delay
Two questions, one snippet. Is the channel connected, and how late is the payload?
const channel = supabase
.channel(`room-${roomId}-messages`)
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "messages" },
onMessage,
)
.subscribe((status) => {
// SUBSCRIBED | CLOSED | CHANNEL_ERROR | TIMED_OUT
console.log("realtime status", status);
});
function onMessage(payload) {
const wroteAt = new Date(payload.new.created_at).getTime();
console.log("delay_ms", Date.now() - wroteAt);
}
Collect samples under a named workload and compare the median and tail latency with a local baseline under materially similar conditions. Use the official table only when its feature and test conditions match what you measured. Browser and database clocks are not perfectly aligned, so read the result as a trend rather than a certified number. The status callback answers the other half: a channel that never reaches SUBSCRIBED is not slow, it is not connected.
Supabase realtime high latency: check the platform before your code
Supabase’s Realtime Reports expose the delivery signals separately. Connected Clients shows active WebSocket connections. Postgres Changes Events shows changes delivered to clients. Paid plans also expose Broadcast-from-Database replication lag and the median RLS execution time for joining or writing to private channels. Those private-channel policies live on realtime.messages; they are not measurements of the RLS policies on a table watched through Postgres Changes. Use the reports to narrow the fault, then benchmark the relevant table policy when Postgres Changes authorization is the suspect.
This post covers the subscription half of that question. The query-and-index half, the ordinary slow-Supabase-query story, is its own separate diagnosis worth running first if you haven’t.
Why postgres_changes gets slower as more users connect
The Postgres Changes documentation states the scaling mechanism directly: Postgres Changes authorizes every event against each subscriber. Change one row in a table with a hundred subscribed users, and Realtime performs a hundred authorization checks, one per user. Total work still rises with both changes and subscribers, but subscriber count multiplies the authorization work attached to each change.
A Postgres Changes event gets more expensive as the number of authorized subscribers grows. Subscriber count multiplies the work attached to each change.
There’s a second constraint stacked on top of the per-subscriber check: Supabase processes Postgres Changes on a single thread to keep event order intact, so a bigger compute add-on does not meaningfully raise the throughput ceiling. Reduce the workload or move the appropriate use case to Broadcast.
One aside from my own work. Repo 25 in the AxonBuild audit corpus is a Python and FastAPI backend behind live VR coaching sessions, and its Performance and Scale pillar scored 15 out of 100, the lowest of the 26 apps I have put through the framework. It is mine. Persistent-connection backends are where this debt hides, and I build these for a living. The corpus gives no Realtime prevalence rate, so this is one app rather than a statistic, and the full ledger behind all 26 audits gets its own dedicated breakdown elsewhere.
Make the RLS policy cheap, specifically
“Keep the policy cheap” is useless advice on its own. Supabase’s RLS performance guide benchmarks the fixes, and four of them apply directly to a subscribed table.
- Index the column the policy filters on, when it is not already a primary key or unique. The guide measures 171 ms down to under 0.1 ms, and notes improvements over 100x on large tables.
- Wrap
auth.uid()and helper functions in(select ...)so Postgres caches the result once per statement instead of re-evaluating it per row. Measured 179 ms to 9 ms in one test, and 11,000 ms to 7 ms in another. - Name the roles with
to authenticatedso anonymous requests are rejected before the policy body runs. Measured 170 ms to under 0.1 ms. - Use a security definer function instead of a subquery across another table inside the policy, so the second table’s own RLS is not evaluated row by row.
-- Before: auth.uid() re-evaluated per row, no role filter,
-- no index on the column the policy filters on.
create policy "read own messages"
on public.messages for select
using (auth.uid() = user_id);
-- After: cached once per statement, anon rejected early, indexed.
create index if not exists messages_user_id_idx
on public.messages (user_id);
create policy "read own messages"
on public.messages for select
to authenticated
using ((select auth.uid()) = user_id);
Those timings come from query benchmarks, not from Realtime. They matter here because the same policy runs once per subscriber on every change: a 179 ms policy and a 9 ms policy are two different applications once a hundred people are watching the same table. The full set of RLS performance techniques is its own subject.
Five subscription patterns that make Realtime slow
These patterns can stay quiet in a short development session and become visible as users, records, or navigations accumulate.
| Risk pattern | Safer default |
|---|---|
| Subscribe to the whole table, filter matches in the client | Filter the subscription and select only the columns this client needs; keep the separate RLS policy cheap and indexed |
| Use postgres_changes for a live view count or a typing indicator | Use Broadcast for anything that isn't really a database write; postgres_changes exists to react to writes, not to move ephemeral state |
| Open one channel per row or per rendered component instance | One channel per resource the user is actually viewing, reused across renders instead of recreated per item in a list |
| Subscribe on mount, never unsubscribe | Every channel closes in a cleanup function, so navigating away actually ends the subscription instead of leaving it open |
| Subscribe in an effect that runs twice in development | Guard with a ref and always removeChannel in cleanup, so the second mount reuses one channel instead of opening a second |
The third pattern, a channel per row or per component, is the Realtime version of the N+1 query: ten rendered rows create ten channels, while a hundred rows create a hundred. The client can multiplex channels over a WebSocket connection, so channel count and socket count are different measurements. The fourth pattern accumulates stale channels as the user navigates because nothing fails immediately when cleanup is missing. The fix is an explicit cleanup function:
// Leaked: subscribes on mount, never unsubscribes.
// Every navigation into this component leaves one more channel subscribed.
useEffect(() => {
supabase
.channel(`room-${roomId}-messages`)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "messages" },
handleNewMessage,
)
.subscribe();
}, [roomId]);
// Cleaned up: the returned function runs on unmount and on
// every roomId change, closing the old channel first.
useEffect(() => {
const channel = supabase
.channel(`room-${roomId}-messages`)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "messages" },
handleNewMessage,
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [roomId]);
Instrument channel joins and removals, navigate repeatedly between subscribed views, and confirm that the active channel count returns to its baseline. A short demo session may not stay open long enough to expose that leak.
Duplicate updates: React StrictMode subscribes twice
React StrictMode mounts every effect twice in development. Two mounts create two subscriptions to the same channel, and every event arrives twice. Supabase’s own guide to the TooManyChannels error names StrictMode alongside components that never unsubscribe, missing effect dependencies, and recreating the client inside a component on every render.
Cleanup alone usually fixes it, because the first mount’s channel is removed before the second subscribes. A ref makes it explicit:
const subscribed = useRef(false);
useEffect(() => {
if (subscribed.current) return;
subscribed.current = true;
const channel = supabase
.channel(`room-${roomId}-messages`)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "messages" },
handleNewMessage,
)
.subscribe();
return () => {
supabase.removeChannel(channel);
subscribed.current = false;
};
}, [roomId]);
Duplicates that survive into production are a different bug. There, two components are usually subscribing to the same table independently, and the fix is one owner per resource rather than a guard.
Realtime does nothing in a server component
Realtime is a browser WebSocket. A subscription written inside a Next.js server component never runs and never errors, because that component executes on the server and returns HTML. There is nothing to see in the console, which is why AI-generated App Router code lands here so often. Move the subscription into a component marked 'use client', inside an effect, and let the server component pass the initial rows down as props.
postgres_changes vs Broadcast: which one your app should use
Once you know which pattern you’re running, the fix may be a narrower subscription or a move to Broadcast. Supabase recommends Broadcast for most database-change use cases and gives a more specific threshold for Postgres Changes: if you expect roughly 3,000 or more concurrent subscribers on the same changes, use Broadcast, which sends each change once and fans it out instead of authorizing the event separately for every subscriber.
| postgres_changes | Broadcast | |
|---|---|---|
| What it’s for | Reacting to a database write: insert, update, delete | Sending a message between clients, whether or not it’s database-backed |
| How it scales | One authorization check per subscriber, per change | One database change is emitted and fanned out; private-channel access is authorized through realtime.messages policies |
| Where the load lands | The database, doing the authorizing | The Realtime server, doing the fan-out |
| Switch to it when | You genuinely need the row that changed | Subscriber count is climbing past low thousands, or the data was never really a table row (presence, cursors, typing indicators) |
The client subscriptions look similar, but database-backed Broadcast also needs a trigger and a private-channel authorization policy. The shorter comparison below only shows the Postgres Changes side:
// postgres_changes: whole table, no filter.
// RLS runs once per connected subscriber, on every insert.
supabase
.channel("messages-channel")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "messages" },
handleNewMessage,
)
.subscribe();
// Filtered: RLS still runs per subscriber, but each channel
// only ever carries rows for one room.
supabase
.channel(`room-${roomId}-messages`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "messages",
filter: `room_id=eq.${roomId}`,
},
handleNewMessage,
)
.subscribe();
The per-subscriber authorization check still runs with a filter in place. The filter reduces the events delivered to that subscription; column selection can also shrink each payload. Neither setting makes an RLS policy indexed or correct. Optimize the table’s policy separately, and benchmark the combined design under the subscriber and change rates you expect.
Supabase realtime connection limits by plan
Authorization overhead is one ceiling. Connection count is a second one. Supabase’s published limits are configurable per project, and these are the defaults; if the ceilings themselves are why you are shopping, replacing Supabase Realtime with Ably, Pusher, or your own WebSocket service is covered on the alternatives page:
| Limit | Free | Pro | Pro, no spend cap | Team | Enterprise |
|---|---|---|---|---|---|
| Concurrent connections | 200 | 500 | 10,000 | 10,000 | 10,000+ |
| Messages per second | 100 | 500 | 2,500 | 2,500 | 2,500+ |
| Channel joins per second | 100 | 500 | 2,500 | 2,500 | 2,500+ |
| Channels per connection | 100 | 100 | 100 | 100 | 100+ |
| Broadcast payload size | 256 KB | 3,000 KB | 3,000 KB | 3,000 KB | 3,000+ KB |
“Realtime is slow on the free tier” can point to this table rather than a performance mystery. Two hundred concurrent connections and a hundred messages a second are real ceilings for a live dashboard. Exceeding the message rate emits the tenant_events error and disconnects clients. supabase-js reconnects automatically after throughput falls below the plan limit.
Do not assume every one of these connections holds a slot against the same ceiling that a plain connection-pool exhaustion error reports from the database side. Realtime clients are WebSockets, while the Realtime service uses its own database connection pools. Monitor the Realtime and database limits separately. That connection ceiling is one more version of the wiring that trips concurrency at 100 users elsewhere in the same stack. And a chatty channel meters like anything else moving bytes off your project: Supabase’s egress bill doesn’t care whether the bytes came from a REST call or a subscription nobody ever unsubscribed from.
Updates stop after a while: the backgrounded tab dropped the socket
The most common “it worked, then it stopped” report has nothing to do with any of those limits. Supabase’s troubleshooting guide spells out why: browsers throttle JavaScript timers in a background tab, the Realtime client’s heartbeat stops firing on schedule, and, as the guide puts it, “the WebSocket connection can silently drop” with the app never finding out on its own. This is the “it worked for an hour, then stopped” report, and it is a client configuration problem, not a Supabase outage.
The guide gives two fixes and recommends both. The first watches the heartbeat and reconnects:
const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
realtime: {
heartbeatCallback: (status) => {
if (status === "disconnected") {
client.connect();
}
},
},
});
The second moves the heartbeat off the throttled main thread onto a Web Worker:
const client = createClient(SUPABASE_URL, SUPABASE_KEY, {
realtime: {
worker: true,
},
});
Set both on the same client. The worker prevents most disconnections; the callback catches the rest. Skip them and a user who tabs away for ten minutes comes back to a UI that looks subscribed and isn’t, which is the same failure that says 200 OK while telling nobody.
Realtime error messages and what they actually mean
Supabase keeps a troubleshooting guide for each of these. The short translation:
| Message | What it usually means |
|---|---|
TIMED_OUT on subscribe | The join never completed. Supabase’s guide attributes it most often to a mismatch between your Node.js version and the realtime-js inside supabase-js, with upgrading Node to the current LTS as the documented fix. Postgres Changes also times out when the database cannot authorize changes fast enough. |
CHANNEL_ERROR | The channel could not be joined. On a private channel the usual cause is authorization: the realtime.messages policy rejected the join, or the client’s token is missing or expired. |
TooManyChannels | Channels are being created and never cleaned up. Supabase names components that do not unsubscribe on unmount, missing effect dependencies, recreating the client on every render, and StrictMode. |
ClientPresenceRateLimitReached | A client sent more than 5 Presence updates in a 30 second window; track() and untrack() both count. Move cursors and typing indicators to Broadcast, or throttle the calls. |
| Concurrent Peak Connections quota | Every connected client subscribed to a channel counts as one, so 100 users in a chat channel is 100 concurrent peak connections. Compare against the plan table above. |
| Project suspended for exceeding quotas | The project went past its Realtime quota rather than degrading quietly. Raise the plan or request custom quotas. |
Before you ship: nine checks
- 01 The table is in the supabase_realtime publication, in every environment
- 02 replica identity full is set if you need old values on update or delete
- 03 Every channel is removed in a cleanup function, verified by navigating back and forth
- 04 One channel per resource the user is viewing, never one per row
- 05 Subscriptions are filtered and select only the columns that client needs
- 06 The policy on the subscribed table is indexed, wrapped in (select ...), and scoped to authenticated
- 07 heartbeatCallback and worker: true are both set on the client
- 08 Expected peak is checked against your plan connection and message limits
- 09 The delay is measured against the published baselines rather than guessed
Common questions about Supabase realtime being slow or not working
Why is my Supabase realtime so slow?
Start with Realtime Reports instead of assuming one cause. Missing updates usually mean an unpublished table, a disconnected channel, or a subscription error. Delayed updates come from replication lag, subscriber-amplified authorization, large payloads, or quota pressure. Check the relevant charts and logs, then inspect whether the app subscribes to a whole table, opens a channel per row, or fails to remove old channels.
Why did Supabase realtime stop working after a while?
Almost always a silently dropped WebSocket. Browsers throttle timers in a backgrounded tab, the client’s heartbeat stops firing on schedule, and the server closes a connection the app still believes is open. Set heartbeatCallback to call client.connect() when the status is disconnected, and set worker: true so the heartbeat runs off the throttled main thread. Supabase recommends using both together.
Why does realtime work locally but not in production?
Because the publication and the policies are database objects, and each environment has its own. A table toggled into supabase_realtime on your local instance is not toggled on in the hosted project, and an RLS policy that lets you read locally may reject the same read in production. Compare the publication membership and the policies on both, in that order.
Why am I getting duplicate realtime events?
In development, React StrictMode mounts effects twice, which creates two subscriptions to the same channel. Return a cleanup function that calls supabase.removeChannel(channel), and optionally guard the effect with a ref. If duplicates persist in production, two different components are subscribing to the same table, and the fix is to give each resource a single owner.
Do I need REPLICA IDENTITY FULL?
Only if you need previous values. By default, an update event carries the new row but not its previous values. A delete event has no new row. alter table public.messages replica identity full adds the old record to update and delete events. Note two limits: RLS policies are not applied to delete statements, and with RLS enabled alongside replica identity full, the deleted row’s old data is limited to its primary key.
How do I know if my realtime channel is actually connected?
Pass a callback to .subscribe() and log the status. It reports SUBSCRIBED, CLOSED, CHANNEL_ERROR, or TIMED_OUT, which separates “connected but slow” from “never connected”. Pair that with Connected Clients in Realtime Reports so you can see whether the socket exists from the platform’s side too.
Is Supabase realtime slower on the free tier?
The free tier is more limited rather than inherently slower. Its defaults are 200 concurrent connections, 100 messages per second, and 100 channel joins per second, against 500 of each on Pro with the spend cap. Exceeding the message rate emits the tenant_events error and disconnects clients. supabase-js reconnects automatically after throughput falls below the plan limit. Treat measured delivery delay without that error as a separate diagnosis.
Does RLS slow down realtime?
Yes. Postgres Changes authorizes every event against every subscriber individually, so one change with a hundred subscribers creates a hundred authorization checks. Index the column the policy filters on, wrap auth.uid() in (select ...), add to authenticated, filter the subscription, select only the columns you need, and benchmark the combination under expected load.
What’s the connection limit for Supabase realtime?
The published defaults are 200 concurrent connections on Free, 500 on Pro with Spend Cap, 10,000 on Pro without Spend Cap and on Team, and 10,000 or more on Enterprise. Messages per second are 100, 500, 2,500, 2,500, and 2,500 or more across those same columns. Limits are configurable per project, so recheck yours before launch.
postgres_changes vs Broadcast, which one should I use?
Supabase recommends Broadcast for database-change subscriptions that need greater scalability and security, and gives roughly 3,000 concurrent subscribers on the same changes as a point to move away from Postgres Changes. Postgres Changes requires less setup and can fit smaller workloads. Benchmark the expected subscriber and write rates before choosing.
When every fix and release still depends on you
AxonBuild can trace the failure, repair the broken workflow, and ship the next change without rebuilding the parts that already work.