DEV Community

Guido
Guido

Posted on

Four Expo + Supabase bugs where the error message sends you the wrong way

Every one of these cost me more than a day. They have the same shape: the app tells you what's wrong, you believe it, and the message points somewhere the bug isn't.

That's what makes them expensive. A bug that says nothing costs you an afternoon of bisecting. A bug that confidently misdirects you costs a day, because you spend it fixing something that was never broken.


1. A query that never resolves and never rejects

Symptom. The app has been backgrounded for a while. You come back, it fetches something, and the spinner spins forever. No error, no timeout, nothing in the logs. It looks exactly like a dead network, so that's where you go looking.

What's actually happening. The underlying fetch got suspended by the OS while the phone was dozing and never woke up. The promise is not pending because the request is slow. It is pending because nothing will ever settle it.

This is why the obvious fixes don't help:

  • Adding a retry does nothing, because the first attempt never finished, so your retry logic never runs.
  • Adding a .catch() does nothing, because there is no rejection to catch.
  • Checking connectivity does nothing, because connectivity is fine.

The fix is to stop trusting the promise to settle:

export function withTimeout<T>(promise: Promise<T>, ms = 8000): Promise<T> {
  return Promise.race([
    promise,
    new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms)
    ),
  ]);
}
Enter fullscreen mode Exit fullscreen mode

Then wrap every call that talks to Supabase:

const { data, error } = await withTimeout(
  supabase.from('notes').select('id, content')
);
Enter fullscreen mode Exit fullscreen mode

A dead promise becomes a real rejection you can show, log, or retry. The important part isn't the helper, it's applying it everywhere rather than at the one call site where you first noticed the hang.


2. Intermittent freezes right after sign in

Symptom. The app locks up shortly after signing in. Not every time — maybe one in five. Never while you're watching, and never on the machine you're debugging on.

What's actually happening. You're awaiting something inside the onAuthStateChange callback:

supabase.auth.onAuthStateChange(async (event, session) => {
  setSession(session);
  const profile = await fetchProfile(session.user.id);  // this is the problem
  setProfile(profile);
});
Enter fullscreen mode Exit fullscreen mode

Supabase documents this: the callback runs while the auth library holds an internal lock, and awaiting a call that itself needs that lock deadlocks the whole thing. Because it depends on timing, it reproduces intermittently, which means you'll blame your own async code long before you suspect the callback contract.

The fix. Keep the callback synchronous. Set state, nothing else:

supabase.auth.onAuthStateChange((event, session) => {
  setSession(session);
});
Enter fullscreen mode Exit fullscreen mode

Then do the side effects in an effect keyed on the user id, where awaiting is safe:

useEffect(() => {
  const userId = session?.user?.id;
  if (!userId) return;
  let cancelled = false;
  void (async () => {
    const profile = await withTimeout(fetchProfile(userId));
    if (!cancelled) setProfile(profile);
  })();
  return () => { cancelled = true; };
}, [session?.user?.id]);
Enter fullscreen mode Exit fullscreen mode

Keying on the id rather than the session object matters too: the session gets a new identity on every token refresh, and you don't want to refetch the profile every hour for no reason.


3. RLS is correct and still hands out the column you hid

This is the one I see most, and the one with the most expensive misdirection at the end.

Symptom. You lock a table down so each user only reads their own row. You test it. It works. And the response still contains every column of that row, including the ones that were never meant to reach the client.

What's actually happening. Row-Level Security filters rows. It does not filter columns. A policy can be perfectly correct and still return the whole row, because deciding which row you may read and deciding which fields of it you may see are two different mechanisms.

Columns are a GRANT question:

revoke select on public.profiles from anon, authenticated;
grant  select (id, display_name, created_at) on public.profiles to authenticated;
Enter fullscreen mode Exit fullscreen mode

Now select('*') fails on that table, which is correct and intended. Select explicit columns.

And now the part that actually burns the afternoon. The same rule applies to writes, and the error will lie to you about it:

// fine — 204, the write is legal
await supabase.from('profiles').update({ display_name });

// 42501 permission denied
await supabase.from('profiles').update({ display_name }).select();

// fine
await supabase.from('profiles').update({ display_name }).select('id, display_name');
Enter fullscreen mode Exit fullscreen mode

The failing one is not failing on the write. PostgREST reads the row back to return a representation, and it reads it with select=* — that read is what gets denied. The error says:

permission denied for table profiles
hint: GRANT SELECT ON public.profiles TO authenticated
Enter fullscreen mode Exit fullscreen mode

Follow that hint and you hand back every column you just spent the afternoon locking down, to fix a bare .select(). The write was never the problem.

While you're there, restrict which columns can be written too. A policy like using (user_id = auth.uid()) lets a user change any column of a row they own — including the ones that decide what they are:

revoke update on public.profiles from authenticated;
grant  update (display_name) on public.profiles to authenticated;
Enter fullscreen mode Exit fullscreen mode

Without that, update profiles set role = 'admin' where id = me passes every row-level check you have, because the row genuinely is theirs.


4. A web export that boots asking for the env var you already set

This one is fresh — I hit it two days ago while recording a demo.

Symptom. npx expo export -p web runs clean. It even prints that it loaded your .env. Exit code 0. You serve the output and the app dies immediately:

[supabase] Missing EXPO_PUBLIC_SUPABASE_URL or EXPO_PUBLIC_SUPABASE_ANON_KEY.
Copy .env.example to .env and fill in the values.
Enter fullscreen mode Exit fullscreen mode

The variable is set. It has been set the whole time. It works in development.

What's actually happening. Metro had a cached transform of that module from a build where the variables weren't available. Babel inlines process.env.EXPO_PUBLIC_* at transform time, so it had already replaced the lookup with undefined, folded the guard to always-true, and dead-code-eliminated everything after it. The compiled module in the bundle was, literally, an unconditional throw:

var e,n=r(d[2]);(e=n)&&e.__esModule,r(d[3]);throw new Error("[supabase] Missing ...
Enter fullscreen mode Exit fullscreen mode

No createClient call anywhere. It didn't fail to find the variable at runtime — the code that reads it doesn't exist in the bundle.

The fix:

npx expo export -p web --clear
Enter fullscreen mode Exit fullscreen mode

The lesson that outlives the bug. A green expo export proves the bundle built. It does not prove the app boots. I had been treating that exit code as a verification step for weeks. It isn't one — open the export before you trust it.


The thread running through all four

In each case the system reported something true and useless. The fetch really did hang. The freeze really was in your async code. Permission really was denied for that table. The variable really was missing from the bundle.

The misdirection is in the gap between what failed and what caused it, and that gap is where the day goes.

The habit that helps isn't knowing these four. It's distrusting green results. My own RLS test suite sat green for weeks and I only found out it could never report a failure when I deliberately broke an assertion to watch it fail — and it didn't. It threw a type error on the line that builds the failure message. Every check had been passing into a branch that had never once executed.

So: break your own checks on purpose, once, and confirm they scream. A test that can't fail looks exactly like a test that passes.


All four of these are handled in a starter I open sourced (MIT) while extracting them from apps I ship: github.com/Guidondor/expo-supabase-starter. Disclosure, since it's mine: there's a paid edition too, but the free one is the full auth, offline queue and RLS base.

If you've hit a fifth one of these, I'd genuinely like to hear it.

Top comments (0)