Back to Blog
Development

Debugging a Memory Leak and Growing Latency in Supabase Realtime

Codoric Team

Codoric Team

2026-06-154 min read
Debugging a Memory Leak and Growing Latency in Supabase Realtime

Debugging a Memory Leak and Growing Latency in Supabase Realtime

A few weeks after shipping a collaborative dashboard built on Supabase Realtime, a client reported something vague but familiar to anyone who's debugged production systems: "it feels slower than when we launched." No errors, no crashes — just a browser tab that got heavier the longer it stayed open, and realtime updates that took noticeably longer to show up after a few hours of use. This is the walkthrough of finding and fixing it.

The symptom

Two separate but related problems showed up:

  1. Client-side memory growth. Chrome's task manager showed the tab's memory climbing steadily, never plateauing, over a multi-hour session.
  2. Increasing update latency. A change made in one browser tab took under 200ms to appear in another tab right after page load — but after a few hours of the app staying open, that same update could take 2-3 seconds.

Both pointed to the same root cause, but it took separating them to see it.

First suspect: the obvious one — unclosed subscriptions

The app subscribed to Postgres changes per-record when a user opened a detail view:

const channel = supabase
  .channel(`record-${recordId}`)
  .on('postgres_changes', { event: '*', schema: 'public', table: 'records', filter: `id=eq.${recordId}` }, handleChange)
  .subscribe();

The bug: when the user navigated away from the detail view, the component unmounted, but the cleanup function was calling channel.unsubscribe() — not supabase.removeChannel(channel). Those look similar but are not equivalent. unsubscribe() stops the channel from receiving events, but the channel object itself, along with its internal listener bindings, stays registered on the client's channel registry. Every detail view a user opened during a session left behind a fully-instantiated channel object that was just... quietly still there.

The fix:

useEffect(() => {
  const channel = supabase
    .channel(`record-${recordId}`)
    .on('postgres_changes', { event: '*', schema: 'public', table: 'records', filter: `id=eq.${recordId}` }, handleChange)
    .subscribe();

  return () => {
    supabase.removeChannel(channel);
  };
}, [recordId]);

removeChannel actually tears down the channel and removes it from the client's internal registry. This alone fixed most of the memory growth — a user who opened 40 different record detail views in a session had previously accumulated 40 live channel objects instead of the 1 they should have had at any given time.

Second suspect: the one that actually explained the latency

Fixing channel cleanup helped memory but only partially helped latency. The remaining cause was more subtle: every open channel — even ones correctly cleaned up on the happy path — was still receiving broadcast traffic for every row change on the records table, not just the one it filtered for, because the filter was applied client-side in the handler instead of at the subscription level for a subset of channels created before a refactor. Those older channels had been set up with:

.on('postgres_changes', { event: '*', schema: 'public', table: 'records' }, (payload) => {
  if (payload.new.id === recordId) handleChange(payload);
})

No filter in the subscription itself — the filtering happened inside the callback. That means every single row update on the records table was being pushed down the websocket connection to every open channel, regardless of whether that channel cared about it. With a handful of users and a low-traffic table, this is invisible. Under real usage — dozens of concurrent users, a table getting frequent writes — every client was receiving a firehose of irrelevant payloads and discarding almost all of them, but only after deserializing and running the filter check on each one.

This is a genuinely easy mistake to make, because the buggy version works correctly — it just does dramatically more work than it needs to. Moving the filter into the subscription itself:

.on('postgres_changes', { event: '*', schema: 'public', table: 'records', filter: `id=eq.${recordId}` }, handleChange)

means Postgres's replication filtering does the work server-side, and the client only receives payloads it actually needs. Latency on updates dropped back to the sub-200ms range regardless of session length, because the client was no longer processing (and the websocket no longer transmitting) irrelevant traffic.

Third factor: too many channels for the use case

Even after both fixes, there was a design question worth raising with the client: was per-record subscription the right pattern at all? For a dashboard where users often had 5-10 records open in different tabs or panels simultaneously, a single channel subscribed to the whole table with a broader filter (e.g., by workspace_id) and client-side routing to the right component turned out to be both simpler and lighter than managing a channel per open record. Fewer websocket subscriptions, fewer places for cleanup bugs to hide.

What to check if you're seeing similar symptoms

If a Supabase Realtime integration is showing memory growth or degrading latency over a session, check these in order:

  1. Are you calling supabase.removeChannel(channel) on cleanup, not just channel.unsubscribe()? This is the single most common cause of leaked channels.
  2. Is your filter applied in the .on() subscription options, not inside the callback? If you're filtering inside the handler, every client is receiving every row change on that table.
  3. How many active channels does a typical session accumulate? Log supabase.getChannels().length periodically during development — if it climbs, something isn't being cleaned up.
  4. Do you actually need per-entity channels, or would one broader channel with client-side routing be simpler and lighter?

None of this shows up in a quick demo or a QA session with one browser tab open for five minutes. It only surfaces under real, sustained usage — which is exactly why it's worth checking for deliberately rather than waiting for a client to notice the app "feels slower."

If you're seeing similar creeping latency or memory growth in a Supabase Realtime setup and can't pin down the cause, that's a debugging session I can help with directly — book a call and we can look at your subscription setup together.