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.
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);
}
});…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);
});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.
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 tokensupabase.auth.getSession()andsupabase.auth.getUser()— you already have the session as the second argument; use itsupabase.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
});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.
Further reading: Fix AuthSessionMissingError: Auth session missing!.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Continue Reading
AuthSessionMissingError: Auth session missing! (Next.js fix)
`AuthSessionMissingError: Auth session missing!` is not a bug report — it is Supabase telling you, accurately, that the client you called had no session to work with. The hard part is that the same message covers a signed-out user, a server client that never received your cookies, a middleware that forgot to forward them, and a session the Auth server has already ended. Each has a different fix.
Better Auth vs Supabase Auth 2026: Honest Verdict
Supabase Auth integrates identity with Supabase APIs and RLS. Better Auth keeps auth in your application; database policies remain possible when you deliberately propagate a verified identity under a restricted role.
Supabase Magic Link: The Production Checklist
A pre-launch checklist for Supabase magic link and email-OTP sign-in: the mailer cap, the redirect allow list, the PKCE email template, and the link-scanner problem that burns single-use tokens.


