Supabase Hangs After onAuthStateChange: The Deadlock
Supabase

Supabase Hangs After onAuthStateChange: The Deadlock

Your app loads, the session arrives, and then every Supabase call after it hangs forever. No error, no rejected promise, no network request — the query simply never returns and the loading spinner stays up until someone reloads the page. If you fetch a profile inside your `onAuthStateChange` callback, this is a documented deadlock in supabase-js, and the fix is one line.

8 min read
Supabase Hangs After onAuthStateChange: The Deadlock

Photo by Michael Geiger on Unsplash

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:

js
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);
  }
});

…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.

js
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);
});

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.

jsx
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]);

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:

js
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
});

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.

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.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.