Mahdi BEN RHOUMAAwaiting 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.
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);
}
});
…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.
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.
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);
});
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]);
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.
The rule is broader than .from(). Inside the callback, avoid awaiting:
supabase.from(...), .rpc(...), .storage.from(...) — anything that needs a tokensupabase.auth.getSession() and supabase.auth.getUser() — you already have
the session as the second argument; use itsupabase.auth.refreshSession(), setSession(), signOut()
fetchProfile() helper or a data-layer wrapperCalls to your own API routes are fine, as long as they do not funnel back through
the same browser client.
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
});
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.
"Supabase stopped working after a while" has a small family of causes, and they
are worth ruling out in order:
getUser() security warning, which is a different
message about a different mistake —
fix the Supabase getUser warning.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