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 token -
supabase.auth.getSession()andsupabase.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
});
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 session is not being persisted or refreshed across navigations — fix Supabase auth session persistence.
- Cookies are set in the wrong place for the App Router, so the server and the client disagree about who is signed in — the complete session and middleware guide and SSR session management.
- The redirect after sign-in lands on a URL that never receives the session — Supabase auth redirect fix.
- The console is full of the
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
Top comments (0)