EP 06

Connected But Nothing's Coming Through


May 2026·6 min read·#realtime#database#debugging

SUBSCRIBED
.

That's what the status said. Subscribed. No errors. But no messages.


The strange situation

I sent a message through the API. It landed in the database — I checked directly in the Supabase dashboard. The data was there.

Nothing came through to the app.

Status:

SUBSCRIBED
. No errors. Just silence.

For a non-developer, here's what makes this particular kind of failure so unnerving: when there's an error, at least there's a cause. This had no error. It just didn't work. And without knowing why, every attempt to fix it risks breaking something else. That scenario played out vividly in my head.

There were two causes. Both were things I hadn't anticipated.


Cause 1 — RLS was eating itself

RLS (Row Level Security) is how Supabase controls who can see which data.

There was a policy on the

users
table that looked like this:

-- only users in the same family can see this row
USING (family_id = (
  SELECT family_id FROM users WHERE id = auth.uid()
))

Looks reasonable. But this is a policy on the

users
table itself. To read from
users
, you need to evaluate this policy. To evaluate this policy, you need to read from
users
. Infinite recursion.

The API used service role (bypasses RLS entirely) — so it worked fine. Realtime evaluates RLS using the user's JWT — so it silently died there. No error message.

The fix was a

SECURITY DEFINER
function.

CREATE OR REPLACE FUNCTION get_my_family_id()
RETURNS uuid
LANGUAGE sql
SECURITY DEFINER  -- runs outside RLS evaluation
AS $$
  SELECT family_id FROM public.users WHERE id = auth.uid();
$$;

-- no more self-reference
CREATE POLICY "family_isolation_users"
  ON users FOR SELECT
  USING (family_id = get_my_family_id());

get_my_family_id()
runs outside the RLS context, breaking the recursion. One function unblocked everything.


Cause 2 — JWT arrived too late

The second problem was timing.

When the Supabase client initializes, the WebSocket opens with the anon key. In React Native, session recovery is asynchronous — the user JWT arrives later. If the subscription opens before the JWT is ready, it opens in an unauthenticated state. Status says

SUBSCRIBED
. But it can't pass RLS.

The fix was one line.

const session = await supabase.auth.getSession()
supabase.realtime.setAuth(session.data.session?.access_token)
// subscribe() after this

Explicitly inject the JWT right before subscribing. It feels like it should happen automatically. It doesn't.


Single-channel pattern

Along the way, I found another issue: opening multiple channels and subscribing separately creates race conditions.

Opening a channel with the same name twice returns the existing channel. Adding

.on()
to an already-subscribed channel throws an error. Navigating in and out of the screen causes things to tangle.

The fix: chain everything onto one channel.

const channel = supabase
  .channel(`room:${roomId}:${Date.now()}`) // timestamp prevents name collision
  .on('postgres_changes', { event: 'INSERT', table: 'chat_messages' }, onInsert)
  .on('postgres_changes', { event: 'UPDATE', table: 'chat_messages' }, onUpdate)
  .on('postgres_changes', { event: 'INSERT', table: 'message_reactions' }, onReaction)
  .subscribe()  // called exactly once, at the end

.subscribe()
goes at the very end, after all the
.on()
chains. Cleanup is a single
supabase.removeChannel(channel)
.


SUBSCRIBED
with nothing coming through. Now I know why.

The database policy was referencing itself in a loop. The JWT was arriving a few hundred milliseconds too late. Neither left an error message. I didn't find the causes until Claude Code wrote diagnostic queries.

Without logs, there's no black box. The reason I built

pepper_logs
in EP 04 became visceral all over again.