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 seeMost likely causeWhere to look
No updates at all, everThe table was never added to the supabase_realtime publicationCheck the publication first
Updates work, then stop after minutes or hoursA backgrounded tab silently dropped the socket and nothing reconnectedThe backgrounded tab
Updates arrive seconds latePer-subscriber RLS cost, payload size, or replication lagBaselines and measurement
Every update fires twiceReact StrictMode subscribes twice in developmentDuplicate updates
Works locally, breaks in productionThe publication and the policies are per environmentCheck the publication first
Works fine, then dies once traffic arrivesConcurrent connection or message-rate quota on your planConnection limits by plan
Subscription in a Next.js page does nothing at allIt was written inside a server componentServer 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:

FeatureConcurrent usersThroughputMedianp95p99
Broadcast over WebSockets32,000224,000 msgs/sec6 ms28 ms213 ms
Broadcast from database80,00010,000 msgs/sec46 ms132 ms159 ms
Realtime Authorization50,000over 150,000 msgs/sec19 ms49 ms96 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.

  1. 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.
  2. 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.
  3. Name the roles with to authenticated so anonymous requests are rejected before the policy body runs. Measured 170 ms to under 0.1 ms.
  4. 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 clientFilter 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 indicatorUse 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 instanceOne channel per resource the user is actually viewing, reused across renders instead of recreated per item in a list
Subscribe on mount, never unsubscribeEvery 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 developmentGuard with a ref and always removeChannel in cleanup, so the second mount reuses one channel instead of opening a second
Risk pattern
Subscribe to the whole table, filter matches in the client
Use postgres_changes for a live view count or a typing indicator
Open one channel per row or per rendered component instance
Subscribe on mount, never unsubscribe
Subscribe in an effect that runs twice in development
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_changesBroadcast
What it’s forReacting to a database write: insert, update, deleteSending a message between clients, whether or not it’s database-backed
How it scalesOne authorization check per subscriber, per changeOne database change is emitted and fanned out; private-channel access is authorized through realtime.messages policies
Where the load landsThe database, doing the authorizingThe Realtime server, doing the fan-out
Switch to it whenYou genuinely need the row that changedSubscriber 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:

LimitFreeProPro, no spend capTeamEnterprise
Concurrent connections20050010,00010,00010,000+
Messages per second1005002,5002,5002,500+
Channel joins per second1005002,5002,5002,500+
Channels per connection100100100100100+
Broadcast payload size256 KB3,000 KB3,000 KB3,000 KB3,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:

MessageWhat it usually means
TIMED_OUT on subscribeThe 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_ERRORThe 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.
TooManyChannelsChannels 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.
ClientPresenceRateLimitReachedA 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 quotaEvery 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 quotasThe project went past its Realtime quota rather than degrading quietly. Raise the plan or request custom quotas.

Before you ship: nine checks

  1. 01 The table is in the supabase_realtime publication, in every environment
  2. 02 replica identity full is set if you need old values on update or delete
  3. 03 Every channel is removed in a cleanup function, verified by navigating back and forth
  4. 04 One channel per resource the user is viewing, never one per row
  5. 05 Subscriptions are filtered and select only the columns that client needs
  6. 06 The policy on the subscribed table is indexed, wrapped in (select ...), and scoped to authenticated
  7. 07 heartbeatCallback and worker: true are both set on the client
  8. 08 Expected peak is checked against your plan connection and message limits
  9. 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.