This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
TL;DR. My Clerk → Firebase auth bridge finished a full, successful sync and then immediately started over, twice a second, forever, on a real phone. Nothing threw. Every iteration worked. I was sure the cause was a remount, then I was sure getToken was memoized, and I had the library source open to prove it. I had the wrong library open. @clerk/clerk-expo wraps @clerk/clerk-react's correctly-memoized getToken to add an offline JWT cache, and drops the useCallback doing it. Every render, new identity. Every new identity, another effect run. Every effect run, a setState that caused the next render. The fix is nine lines. Finding it took building an instrument, because a re-run and a remount look identical in logs and have opposite fixes.
The setup
Vesprit is a study app. Clerk handles identity, Firestore holds the data, and Firestore's security rules need a Firebase auth.uid, which Clerk does not give you. So there's a bridge: take the Clerk session token, exchange it at a Cloudflare Worker for a Firebase custom token, sign in to Firebase with that. Three hops, one useEffect, one status the UI reads.
export function useFirebaseAuthBridge(): BridgeState {
const { isLoaded, isSignedIn, userId, getToken } = useAuth();
const [status, setSyncStatus] = useState<BridgeStatus>('signed-out');
useEffect(() => {
// … getToken() → exchangeForFirebaseToken() → signInWithCustomToken()
// … setSyncStatus('syncing') … setSyncStatus('ready')
}, [isLoaded, isSignedIn, userId, getToken, attempt]);
return { status, error, retry };
}
getToken is in the dependency array because the lint rule says it should be, and because Clerk's own documentation shows getToken used inside effects. That is the entire bug, sitting in plain sight, looking like correct code.

Not a stall. This row is completing successfully and then starting again.
What was wrong
On device, the terminal read like this:
[firebase-bridge +2ms] requesting Clerk session token
[firebase-bridge +6ms] exchanging Clerk token for a Firebase custom token
[firebase-bridge +67ms] signing in to Firebase with the custom token
[firebase-bridge +716ms] ready — Firestore rules will now see request.auth.uid
[firebase-bridge +2ms] requesting Clerk session token <- and again, forever
Read that last line carefully, because it's what made this hard. The sync succeeded. It reached ready. Then it did the whole thing again, roughly twice a second, indefinitely.
Every one of those cycles minted a real Firebase custom token: a network call to Clerk, an invocation of my Cloudflare Worker, and a Firebase sign-in. On a phone. On battery. There was no error state to find, no exception to catch, no failed request in the network log. The app was working perfectly, several times a second, forever.
This is the class of bug that error tracking cannot see.
The two things I was sure about, and why both were wrong
Wrong hypothesis #1: "The effect is re-running and cancelling its own result."
This was the first theory, and it was mine and my agent's together. It's the standard shape: an effect re-fires, the cleanup sets cancelled = true, and the in-flight result gets discarded before it can land.
Disproved by absence. The console.error in the bridge's catch block sits outside the cancelled guard, so it fires unconditionally whenever the catch is reached. It never fired. Nothing was throwing. Silence from that line was the whole diagnosis.
Wrong hypothesis #2: "getToken is memoized, so the dependency is stable."
This is the one worth writing the post about. I said this confidently. I had evidence. Here is the evidence I had, from @clerk/clerk-react:
const getToken = useCallback(createGetToken(isomorphicClerk), [isomorphicClerk]);
Memoized on a stable singleton. Stable identity. The effect runs once. Case closed. The loop must be a remount, which is a problem one layer above this hook, in how the provider is mounted.
I was reading the wrong package.
The app imports from @clerk/clerk-expo. That's a different package with its own useAuth, and I had generalised from the source of a library the app does not import.
The instrument, because logs couldn't answer it
Here's the thing that made this more than a lucky guess: a re-run and a remount produce identical logs, and they have opposite fixes. A changed dependency is fixed inside the hook. A remount is fixed above it. I had a 50/50 and no way to settle it by reading.
So before fixing anything, I built something to tell them apart:
// src/lib/dep-trace.ts
//
// `nextInstanceId` is module scope on purpose. Component-local state cannot
// distinguish "this instance ran twice" from "two instances each ran once",
// which is precisely the ambiguity in a paired log.
let nextInstanceId = 1;
export function claimInstanceId(): number {
return nextInstanceId++;
}
export function diffDependencies(
previous: readonly unknown[] | null,
next: readonly unknown[],
names: readonly string[],
): string {
if (previous === null) {
return 'FIRST RUN (fresh mount — if you see this repeatedly, the component is remounting)';
}
const changed = names.filter((name, index) => !Object.is(previous[index], next[index]));
if (changed.length === 0) {
return 'RE-RUN with NO changed dependency (double-invoke or Fast Refresh)';
}
return `RE-RUN — changed: ${changed.join(', ')}`;
}
Module scope is the entire point. If the counter lived in component state it would reset with the component, and "instance #1 ran twice" would be indistinguishable from "instance #1 and instance #1", which is exactly the question being asked.
Wired into the bridge:
+ const [instanceId] = useState(claimInstanceId);
+ const previousDepsRef = useRef<readonly unknown[] | null>(null);
+
+ useEffect(() => {
+ if (__DEV__) console.log(`[firebase-bridge #${instanceId}] MOUNTED`);
+ return () => {
+ if (__DEV__) console.log(`[firebase-bridge #${instanceId}] UNMOUNTED`);
+ };
+ }, [instanceId]);
+
useEffect(() => {
+ if (__DEV__) {
+ const deps = [isLoaded, isSignedIn, userId, getToken, attempt];
+ console.log(
+ `[firebase-bridge #${instanceId}] effect: ` +
+ diffDependencies(previousDepsRef.current, deps, [
+ 'isLoaded', 'isSignedIn', 'userId', 'getToken', 'attempt',
+ ]),
+ );
+ previousDepsRef.current = deps;
+ }
One detail I'd have got wrong a year ago: the instance id comes from a lazy useState, not a useRef. Reading a ref during render violates the Rules of React, and this component is compiled by React Compiler, which is entitled to assume those rules hold. A lazy initializer runs exactly once per instance and is safe to read while rendering.
One device run answered it. Every line read #1: the component never remounted, not once. And every effect run read:
[firebase-bridge #1] effect: RE-RUN — changed: getToken

The instrument naming the dependency I had just finished arguing was stable.
The root cause
@clerk/clerk-expo@2.19.31, dist/hooks/useAuth.js, verbatim, with only the bundler's CommonJS preamble stripped:
var import_clerk_react = require("@clerk/clerk-react");
var import_error = require("@clerk/shared/error");
var import_cache = require("../cache");
const useAuth = (initialAuthState) => {
const { getToken: getTokenBase, ...rest } = (0, import_clerk_react.useAuth)(initialAuthState);
const getToken = (opts) => getTokenBase(opts).then((token) => {
if (!opts && import_cache.SessionJWTCache.checkInit()) {
if (token) {
void import_cache.SessionJWTCache.save(token);
} else {
void import_cache.SessionJWTCache.remove();
}
}
return token;
}).catch((error) => {
if (!opts && import_cache.SessionJWTCache.checkInit() && (0, import_error.isNetworkError)(error)) {
return import_cache.SessionJWTCache.load();
}
throw error;
});
return { ...rest, getToken };
};
That first line is the one I misread for days. It looks recursive (useAuth calling useAuth), but the one on the right is import_clerk_react.useAuth, a different package's hook pulled in under an internal name. In a post whose entire moral is "read the right source," it would be poor form to paraphrase this, so that's the literal file.
clerk-expo takes clerk-react's properly-memoized getToken, wraps it to add an offline JWT cache so tokens survive going offline (a genuinely good feature), and returns the wrapper. There is no useCallback around the wrapper. New function identity, every render.
The memoization still exists. It's just one layer down, wrapped in something that isn't memoized, which makes it worthless to anyone downstream.
And no, this isn't fixed, and I'm not the first to notice the ingredient. @clerk/clerk-expo is deprecated now (Clerk replaced it with @clerk/expo in Core 3), so a defect in it would be a fair thing to shrug at. I checked the replacement. @clerk/expo@4.5.2, dist/hooks/useAuth.js, as of 2026-08-24:
const useAuth = (options) => {
const { getToken: getTokenBase, ...rest } = (0, _clerk_react.useAuth)(options);
const getToken = (opts) => getTokenBase(opts).then(/* SessionJWTCache save/remove */)
.catch(/* offline fallback */);
return { ...rest, getToken };
};
New package, new build tooling, same wrapper, same missing useCallback. The file contains zero occurrences of useCallback or useMemo.
The un-memoized identity is also already corroborated in the wild, which I'd rather say than pretend I found it alone: get-convex/convex-js#176 (July 2026) ships an eslint-disable-next-line react-hooks/exhaustive-deps whose comment states it flatly: Clerk's Expo useAuth does not memoize getToken. So the fact is known. It's just known in a workaround comment inside somebody else's repository, not in an issue on Clerk's tracker, where I couldn't find it at all.
What I haven't seen written down anywhere is the part below: that when the effect also sets state, the missing memoization stops being a lint annoyance and becomes a self-sustaining loop that mints real credentials several times a second and never once looks like a failure. That consequence is my own diagnosis, and it's the reason a known-but-under-reported trap is worth a post.
And because the effect calls setSyncStatus, the loop needs nothing external to sustain it:
effect runs → setSyncStatus → re-render → new getToken identity → effect runs → …
It is a perpetual motion machine made of correct-looking code. Any useEffect that lists Clerk's Expo getToken in its dependencies and sets state has this, on the deprecated package and the current one alike. Clerk's docs show getToken used inside effects.
The fix
- const { isLoaded, isSignedIn, userId, getToken } = useAuth();
+ // `getToken` is destructured under a different name and immediately stabilised.
+ // @clerk/clerk-expo rebuilds it on EVERY render (it wraps clerk-react's memoized
+ // version to add an offline JWT cache and does not re-memoize), so using it
+ // directly in the dependency array below re-runs the effect on every render —
+ // and since the effect calls setSyncStatus, it re-renders itself, forever.
+ const { isLoaded, isSignedIn, userId, getToken: getTokenUnstable } = useAuth();
+ const getToken = useStableCallback(getTokenUnstable);
And the hook itself, the "latest ref" pattern, which is React's own useEffectEvent proposal hand-rolled while that stays experimental:
export function useStableCallback<Args extends unknown[], Result>(
callback: (...args: Args) => Result,
): (...args: Args) => Result {
const latest = useRef(callback);
// No dependency array on purpose: this must run after EVERY render, since the
// whole premise is that `callback` changes identity every time.
useEffect(() => {
latest.current = callback;
});
// Empty deps, so the identity handed to callers never changes.
return useCallback((...args: Args) => latest.current(...args), []);
}
Nine lines of body. Days of being confidently wrong.
The fix I didn't make, which is the interesting one
The obvious move is to delete getToken from the dependency array. The lint rule goes quiet, the loop stops, you move on.
That installs a worse bug than the one you fixed. The effect then closes over whichever getToken existed at mount and calls that one forever. After Clerk refreshes the session, the mount-time getToken is stale, so your bridge starts minting Firebase tokens from an expired Clerk session, and now you have an auth failure that only appears after the app has been open long enough to refresh. Good luck reproducing that.
The distinction is a test, not a comment:
it('calls the LATEST callback, not the one captured at mount', async () => {
// The reason this is not just "delete the dependency". A frozen closure would
// keep calling the mount-time getToken and hand back a stale session token
// after Clerk refreshes.
const { result, rerender } = await renderHook(
({ value }: { value: string }) => useStableCallback(() => value),
{ initialProps: { value: 'first' } },
);
expect(result.current()).toBe('first');
await rerender({ value: 'second' });
expect(result.current()).toBe('second');
});
Plus one for the loop itself, and one for a re-render arriving mid-flight while a token exchange is in the air:
it('keeps one identity even when the input changes on every render', async () => {
// This is the exact shape of the bug.
const { result, rerender } = await renderHook(() => useStableCallback(() => 'token'));
const first = result.current;
await rerender({});
await rerender({});
expect(result.current).toBe(first);
});
it('survives a re-render mid-flight, still resolving the original call', async () => {
const { result, rerender } = await renderHook(
({ value }: { value: string }) => useStableCallback(async () => value),
{ initialProps: { value: 'first' } },
);
const pending = result.current();
await rerender({ value: 'second' });
await expect(pending).resolves.toBe('first');
});
There's one sharp edge worth documenting, and it's in the file: do not call the returned function during render. It reads a ref updated in an effect, so during render it hands back the previous render's callback. Effects, event handlers, and async work only.

The same screen after. The interesting part is the empty space underneath.
Honest scale
I want to be straight about how big this was, because the temptation to inflate it is real.
Nobody was affected. Vesprit is pre-launch: no users, no store listing yet. This was caught on my own device during development, on a build only I was running. Nothing leaked, nothing was corrupted, no one's battery died.
It was my bug too, not purely Clerk's. Clerk's wrapper is the defect, but I put getToken in a dependency array and then reasoned about it from the wrong package's source. The library made the trap; I walked into it and then argued the trap wasn't there.
The fix is a workaround, not a patch, and the trap is still open. I have an upstream report drafted against clerk/javascript, retargeted at @clerk/expo@4.5.2 now that I've confirmed the current package still ships it, and I haven't filed it yet. Until I do, this is still there for everyone else, which is the part that actually bothers me.
I did not discover the ingredient, only the consequence. The missing memoization is already documented, in passing, in an eslint-disable comment in convex-js#176. I'd rather say that than sell this as a lone discovery. What I contribute is the diagnosis of what it does when the effect sets state, the instrument that distinguishes it from a remount, and a fix that doesn't quietly swap it for a stale-token bug.
What's real: the loop was a full token mint several times a second on a mobile device, indefinitely, and it would have shipped. The pattern Clerk's own documentation shows still produces it in the current package, and the people who have it will not see it, because nothing about it looks or logs like a failure.
What I take from it
When two causes produce identical logs, build the instrument before you build the fix. I could have shipped a plausible guess in ten minutes and been wrong in a way I wouldn't have discovered for weeks. The instrument was thirty lines and settled it in one device run. It's still in the codebase.
Reading the source is evidence. Reading the right source is the evidence. I did the thing that feels most rigorous (go read the library), and it made me more confident and more wrong at the same time. @clerk/clerk-react and @clerk/clerk-expo are different packages, and my agent and I both generalised from the one that wasn't installed. Now I check node_modules for the package name in the import statement, not the package I assume is underneath.
Success is not a signal that things are fine. Every metric in this system was green. Every request returned 200. Every sync completed. The bug was that it completed again. I've since started thinking about "how often does this correct thing happen" as a first-class question, not just "does it work."
A memoization one layer down is not a memoization. If you wrap a stable callback, you own its stability now.
Two things I'd genuinely like answers to:
Has anyone found a way to catch this class of bug automatically? Not the loop itself: a render-count or effect-count budget in dev would catch that. I mean the general case: an operation that is individually correct and collectively pathological. Every tool I have is built around things going wrong, and this never went wrong.
And how do you handle the un-memoized-callback-from-a-library problem at scale? useStableCallback fixes one call site. There are dozens of hooks across the ecosystem returning fresh identities every render, and the honest answer for most of them is "wrap it and hope you noticed." Is there something better than a per-site workaround?
Top comments (0)