Supabase Hangs After onAuthStateChange: The Deadlock

Supabase Hangs After onAuthStateChange: The Deadlock

# supabase# supabaseauth# nextjs# react
Supabase Hangs After onAuthStateChange: The DeadlockMahdi BEN RHOUMA

Awaiting a Supabase call inside onAuthStateChange deadlocks the client: the next query anywhere in your app never returns. Here is the cause and the fix.

The bug report is always the same shape, and it never mentions authentication:

After a while, every Supabase query in the app just hangs. No error, nothing
in the network tab. Reloading fixes it for about an hour.

Nothing rejects, so there is nothing to catch. The promise from
supabase.from('profiles').select() simply never settles. Your try/catch is
irrelevant, your error boundary never renders, and your loading state stays true
forever.

If your app calls anything on the Supabase client inside an
onAuthStateChange callback, that is the cause, and it is documented.

The mechanism

supabase-js serialises auth events. When a session changes — sign-in,
sign-out, and the hourly token refresh — the client takes an internal lock,
delivers the event to every subscriber, and releases the lock when the callbacks
have returned.

Any Supabase call you make needs that same lock, because it has to resolve the
current access token before it can attach it to the request.

So this:

supabase.auth.onAuthStateChange(async (event, session) => {
  if (session) {
    // waits for a lock this callback is already holding
    const { data } = await supabase
      .from('profiles')
      .select('*')
      .eq('id', session.user.id)
      .single();

    setProfile(data);
  }
});
Enter fullscreen mode Exit fullscreen mode

…waits, inside the lock, for the lock. The callback cannot return until the
query resolves; the query cannot resolve until the callback returns.

Supabase's own troubleshooting entry,
Why is my supabase API call not returning?,
states it plainly: there is a bug in supabase-js which results in a deadlock if
any async API call is made in onAuthStateChange code, and if a call is made in
the handler then the next Supabase call anywhere using that client will hang and
not return. The tracking issue is
supabase/auth-js#762.

That second half is what makes it so hard to attribute. The deadlock does not
stay in the callback. Every later call on that client — a query in a completely
unrelated component, a getSession() in your middleware helper, a realtime
subscribe — queues behind a lock that will never be released.

Why it looks intermittent

Three events run through the same callback: SIGNED_IN, TOKEN_REFRESHED, and
INITIAL_SESSION.

Access tokens are short-lived and refreshed automatically, so
TOKEN_REFRESHED fires roughly every hour for an open tab. An app can therefore
work perfectly through a morning of navigation and then freeze at 11:04 with no
deploy, no config change and no error — which is exactly how it gets
misdiagnosed as a network problem, a Vercel cold start, or a Supabase incident.

The same timing explains the "reloading fixes it" report: a reload builds a fresh
client with a fresh lock.

The fix

Keep the callback synchronous with respect to Supabase. Set your state, then let
the work happen after the callback has returned.

supabase.auth.onAuthStateChange((event, session) => {
  // Synchronous: safe, and this is all most apps actually need here.
  setSession(session);

  if (!session) {
    setProfile(null);
    return;
  }

  // Deferred: runs on the next macrotask, after the lock is released.
  setTimeout(() => {
    void loadProfile(session.user.id);
  }, 0);
});
Enter fullscreen mode Exit fullscreen mode

setTimeout(..., 0) is not a superstition here. It moves the query into a later
task, after onAuthStateChange has returned and released the lock. A
microtask — Promise.resolve().then(...), or just dropping the await — is not
reliably enough, because microtasks can drain before the synchronous caller
finishes unwinding.

In React the cleaner version avoids the timer entirely: store the session in
state and let a separate effect do the fetching.

useEffect(() => {
  const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
    setSession(session);           // nothing but state
  });
  return () => sub.subscription.unsubscribe();
}, []);

useEffect(() => {
  if (!session?.user) return;
  let cancelled = false;

  supabase
    .from('profiles')
    .select('*')
    .eq('id', session.user.id)
    .single()
    .then(({ data }) => {
      if (!cancelled) setProfile(data);
    });

  return () => { cancelled = true; };
}, [session?.user?.id]);
Enter fullscreen mode Exit fullscreen mode

Two effects, one job each. The subscription only ever touches React state, and
the query runs in a normal render cycle where nothing holds the auth lock.

What still counts as "a Supabase call"

The rule is broader than .from(). Inside the callback, avoid awaiting:

  • supabase.from(...), .rpc(...), .storage.from(...) — anything that needs a token
  • supabase.auth.getSession() and supabase.auth.getUser() — you already have the session as the second argument; use it
  • supabase.auth.refreshSession(), setSession(), signOut()
  • anything that transitively calls one of the above, including your own fetchProfile() helper or a data-layer wrapper

Calls to your own API routes are fine, as long as they do not funnel back through
the same browser client.

Confirming it is this and not something else

Two checks, thirty seconds each.

One. Comment out the body of your onAuthStateChange callback, leaving only
a console.log. If the hangs stop, you have your answer.

Two. Instrument the boundary — if the first line logs and the second does
not, the query never settled:

supabase.auth.onAuthStateChange(async (event, session) => {
  console.log('[auth] callback in', event);
  const { data } = await supabase.from('profiles').select('id').limit(1);
  console.log('[auth] callback out', data);   // never prints when deadlocked
});
Enter fullscreen mode Exit fullscreen mode

A hang with in and no out is a deadlock. An error in between is a different
bug — probably RLS or a missing session, and
debugging Supabase RLS issues covers that
path.

Related failures that look identical from the outside

"Supabase stopped working after a while" has a small family of causes, and they
are worth ruling out in order:

The deadlock is the one that produces silence rather than a message. If nothing
is logged, nothing is thrown, and nothing appears in the network tab, look at
what your auth callback is awaiting.


Originally published at https://www.iloveblogs.blog